mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
223
Commits
v1.5.1
..
e10920d142
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
49c872a8ec | ||
|
|
78af1f9bdd | ||
|
|
32b3d2ade1 | ||
|
|
64f26e76f6 | ||
|
|
68c42a0330 | ||
|
|
0d1bb196e2 | ||
|
|
e31f82c0d6 | ||
|
|
f82dcc3c17 | ||
|
|
4378e1b430 | ||
|
|
5eeaf4e32e | ||
|
|
82f968e51f | ||
|
|
a5ad16ac27 | ||
|
|
6a1a8357bf | ||
|
|
31cfd19b6b | ||
|
|
4cd9ad00d2 | ||
|
|
31dcc76ee2 | ||
|
|
bf6ac7b405 | ||
|
|
1bfb9a647d | ||
|
|
e9272ec29a | ||
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce | ||
|
|
bf51309fbb | ||
|
|
23b23f39b9 | ||
|
|
0daad46591 | ||
|
|
a2a8f1c7b6 | ||
|
|
850fe0f044 | ||
|
|
7279668955 | ||
|
|
5048cc0720 | ||
|
|
3f785febc8 | ||
|
|
907126d152 | ||
|
|
c1f57957c0 | ||
|
|
cd2ec8240b | ||
|
|
16bde22604 | ||
|
|
4900de0444 | ||
|
|
79ed7c9f85 | ||
|
|
9a21504f40 | ||
|
|
045b23fc47 | ||
|
|
42b765b113 | ||
|
|
f9fe53285f | ||
|
|
46ba8b9950 | ||
|
|
f096681a2b | ||
|
|
fe0c66c561 | ||
|
|
add921b3b3 | ||
|
|
f1b716fb31 | ||
|
|
8f2704fd41 | ||
|
|
7e0406f751 | ||
|
|
d5c2ab2b0f | ||
|
|
9e0908f43c | ||
|
|
7b3be2c819 | ||
|
|
a8183166ac | ||
|
|
f6169b7610 | ||
|
|
493f901ec1 | ||
|
|
b4dfb0409b | ||
|
|
a06cb0ed71 | ||
|
|
b0242265f4 | ||
|
|
b444c0f4b8 | ||
|
|
b1b25fe678 | ||
|
|
7cf2ab7c88 | ||
|
|
1918735520 | ||
|
|
c652cc1533 | ||
|
|
e9aef72df7 | ||
|
|
9adb24d4bb | ||
|
|
563570e3e0 | ||
|
|
3035a77579 | ||
|
|
28333f0e9b | ||
|
|
91320a4eba | ||
|
|
19b96a7aca | ||
|
|
18e0396c0a | ||
|
|
18d3490c4f | ||
|
|
615caf5946 | ||
|
|
1a10df4a18 | ||
|
|
d4749d3f82 | ||
|
|
5ed7c949c1 | ||
|
|
af70cab766 | ||
|
|
bfea5d0a1c | ||
|
|
cda654e1c3 | ||
|
|
1ee7b0f31b | ||
|
|
2d2cbea530 | ||
|
|
4f5d992f10 | ||
|
|
667afa305b | ||
|
|
85bd2fa4bf | ||
|
|
fd9707c396 | ||
|
|
192071e4a7 | ||
|
|
fcf7c80daa | ||
|
|
ed9251c014 | ||
|
|
a75955ca6d | ||
|
|
03f7fbf601 | ||
|
|
a63336764f | ||
|
|
f56d7f01ca | ||
|
|
8ff60aed24 | ||
|
|
749de4e2e1 | ||
|
|
ea9e238aa7 | ||
|
|
22d267f5bc | ||
|
|
18eefd1174 | ||
|
|
d468745841 | ||
|
|
970621c459 | ||
|
|
385a873e65 | ||
|
|
56185ecb69 | ||
|
|
04ebfc7021 | ||
|
|
c50247b8fe | ||
|
|
776408e9d0 | ||
|
|
e641da517d | ||
|
|
b7878ffe01 | ||
|
|
bbad9d60a7 | ||
|
|
ed58467766 | ||
|
|
2f911e66a6 | ||
|
|
d06e050162 | ||
|
|
d0dc31ce86 | ||
|
|
f64abaa75d | ||
|
|
7312086f92 | ||
|
|
3e4c104e1d | ||
|
|
17ceec45b1 | ||
|
|
2685741386 | ||
|
|
83a1fc2376 | ||
|
|
06431c4145 | ||
|
|
700910099b | ||
|
|
6b671450a8 | ||
|
|
c0df6d1c16 | ||
|
|
35f9512d94 | ||
|
|
9e39161fc7 | ||
|
|
7c58282e42 | ||
|
|
e0d81f2733 | ||
|
|
1d23b3fe5e | ||
|
|
a0d4d7a1ff | ||
|
|
2f1b61e883 | ||
|
|
4e62c90700 | ||
|
|
7afb496eb0 | ||
|
|
5809e3eebc | ||
|
|
2e9bbe6801 | ||
|
|
dc0eec7c54 | ||
|
|
a0605299f0 | ||
|
|
db68437a0b | ||
|
|
77d8411ea9 | ||
|
|
0c1ab3db48 | ||
|
|
6cc6e94b91 | ||
|
|
37ae493fa7 | ||
|
|
33f7c5d88a | ||
|
|
c6c8979772 | ||
|
|
a00279f47d | ||
|
|
669d7ef242 | ||
|
|
97d2117e15 | ||
|
|
429b747710 | ||
|
|
a06853835d | ||
|
|
c4ff063865 | ||
|
|
70b0a3a394 | ||
|
|
e7c07fda4e | ||
|
|
0a001bebcc |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx vite *)",
|
||||
"Bash(npx tsc *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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://ifdian.net/a/agoodman
|
||||
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Project Wiki/ 项目文档
|
||||
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||
url: https://nodewarden.app
|
||||
about: |
|
||||
Please check the documentation for common questions and troubleshooting steps.
|
||||
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why? -->
|
||||
|
||||
## Change Type
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Compatibility update
|
||||
- [ ] Documentation
|
||||
- [ ] Refactor
|
||||
|
||||
## Cross-File Checklist
|
||||
|
||||
- [ ] I read `CONTRIBUTING.md`.
|
||||
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
|
||||
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
|
||||
- [ ] User-facing text changes, if any, updated all locale files.
|
||||
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
|
||||
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] `npx tsc -p tsconfig.json --noEmit`
|
||||
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
|
||||
- [ ] `npm run i18n:validate`
|
||||
- [ ] `npm run build`
|
||||
|
||||
## Notes
|
||||
|
||||
<!-- Anything reviewers should pay special attention to? -->
|
||||
@@ -0,0 +1,33 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:00"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
ignore:
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "05:10"
|
||||
timezone: "Asia/Shanghai"
|
||||
open-pull-requests-limit: 0
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -1,467 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Security Report Generator (Node.js)
|
||||
* Better, faster, and more maintainable than Bash.
|
||||
*/
|
||||
|
||||
class SecurityReport {
|
||||
constructor() {
|
||||
this.results = {
|
||||
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
|
||||
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
|
||||
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
|
||||
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
|
||||
coverage: { actions: 0, js: 0, ts: 0 },
|
||||
artifactUris: []
|
||||
};
|
||||
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
this.runId = process.env.GITHUB_RUN_ID || '0';
|
||||
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
|
||||
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
|
||||
|
||||
this.locales = {
|
||||
zh: {
|
||||
filename: 'security-report-cn.md',
|
||||
switcher: '[English](security-report.md) | 中文',
|
||||
title: '🛡️ 安全审计与透明度报告',
|
||||
grade: '安全评级',
|
||||
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
|
||||
auditTime: '📅 审计时间',
|
||||
runId: '📝 运行 ID',
|
||||
env: '🛠️ 环境',
|
||||
dashboard: '📉 实时安全仪表盘',
|
||||
tool: '工具',
|
||||
status: '状态',
|
||||
findings: '发现项',
|
||||
leaks: '泄露',
|
||||
vulns: '漏洞',
|
||||
alerts: '告警',
|
||||
coverageTitle: '🔍 扫描覆盖范围',
|
||||
module: '模块',
|
||||
auditedFiles: '已审计文件',
|
||||
coverage: '覆盖率',
|
||||
detailedFindings: '🔍 详细发现项',
|
||||
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
|
||||
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
|
||||
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
|
||||
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
|
||||
snykTitle: '📦 第三方依赖',
|
||||
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
|
||||
package: '软件包',
|
||||
severity: '严重程度',
|
||||
description: '描述',
|
||||
fixPlan: '修复方案',
|
||||
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
|
||||
codeqlSummary: '#### 摘要',
|
||||
rulesChecked: '已检查规则',
|
||||
totalAlerts: '告警总数',
|
||||
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
|
||||
ruleId: '规则 ID',
|
||||
level: '级别',
|
||||
location: '位置',
|
||||
auditedList: '📂 已审计文件列表',
|
||||
guideTitle: '⚠️ 操作指南',
|
||||
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
|
||||
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
||||
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
||||
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
||||
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
||||
auditedIcon: '✅ **已审计**',
|
||||
noFiles: '未检索到文件。',
|
||||
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
||||
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
|
||||
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
|
||||
},
|
||||
en: {
|
||||
filename: 'security-report.md',
|
||||
switcher: 'English | [中文](security-report-cn.md)',
|
||||
title: '🛡️ Security Audit & Transparency Report',
|
||||
grade: 'Security Grade',
|
||||
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
|
||||
auditTime: '📅 Audit Time',
|
||||
runId: '📝 Run ID',
|
||||
env: '🛠️ Environment',
|
||||
dashboard: '📉 Real-time Security Dashboard',
|
||||
tool: 'Tool',
|
||||
status: 'Status',
|
||||
findings: 'Findings',
|
||||
leaks: 'Leaks',
|
||||
vulns: 'Vulns',
|
||||
alerts: 'Alerts',
|
||||
coverageTitle: '🔍 Scan Coverage',
|
||||
module: 'Module',
|
||||
auditedFiles: 'Audited Files',
|
||||
coverage: 'Coverage',
|
||||
detailedFindings: '🔍 Detailed Findings',
|
||||
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
|
||||
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
|
||||
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
|
||||
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
|
||||
snykTitle: '📦 Third-party Dependencies',
|
||||
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
|
||||
package: 'Package',
|
||||
severity: 'Severity',
|
||||
description: 'Description',
|
||||
fixPlan: 'Fix Plan',
|
||||
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
|
||||
codeqlSummary: '#### Summary',
|
||||
rulesChecked: 'Rules Checked',
|
||||
totalAlerts: 'Total Alerts',
|
||||
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
|
||||
ruleId: 'Rule ID',
|
||||
level: 'Level',
|
||||
location: 'Location',
|
||||
auditedList: '📂 Audited File List',
|
||||
guideTitle: '⚠️ Action Guide',
|
||||
guideDesc: 'If you see a **FAIL** status or serious code issues:',
|
||||
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
||||
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
||||
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
||||
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
||||
auditedIcon: '✅ **Audited**',
|
||||
noFiles: 'No files found.',
|
||||
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
||||
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
|
||||
trivySafe: '✅ **SAFE**: No container configuration defects found.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Data Parsers ---
|
||||
|
||||
async parseCodeQL() {
|
||||
const sarifPath = 'sarif-results';
|
||||
if (!fs.existsSync(sarifPath)) return;
|
||||
|
||||
const files = this.globFiles(sarifPath, '.sarif');
|
||||
let totalAlerts = 0;
|
||||
let rulesSet = new Set();
|
||||
let findings = [];
|
||||
let artifactUris = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
for (const run of data.runs || []) {
|
||||
// Collect Rules
|
||||
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
|
||||
(run.tool.extensions || []).forEach(ext => {
|
||||
(ext.rules || []).forEach(r => rulesSet.add(r.id));
|
||||
});
|
||||
|
||||
// Collect Results
|
||||
for (const res of run.results || []) {
|
||||
totalAlerts++;
|
||||
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
|
||||
findings.push({
|
||||
id: res.ruleId,
|
||||
level: res.level || 'warning',
|
||||
path: loc.artifactLocation?.uri || 'Global',
|
||||
line: loc.region?.startLine || '-',
|
||||
message: res.message?.text || 'No description'
|
||||
});
|
||||
}
|
||||
|
||||
// Track Coverage (Deduplicated)
|
||||
(run.artifacts || []).forEach(art => {
|
||||
const uri = art.location?.uri || '';
|
||||
if (uri) artifactUris.add(uri);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.results.artifactUris = Array.from(artifactUris).sort();
|
||||
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
|
||||
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
|
||||
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
|
||||
|
||||
this.results.codeql.alertCount = totalAlerts;
|
||||
this.results.codeql.rulesCount = rulesSet.size;
|
||||
this.results.codeql.findings = findings;
|
||||
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
|
||||
}
|
||||
|
||||
async parseSnyk() {
|
||||
const jsonPath = 'snyk_result.json';
|
||||
if (!fs.existsSync(jsonPath)) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
const projects = Array.isArray(data) ? data : [data];
|
||||
let vulnTotal = 0;
|
||||
let findings = [];
|
||||
|
||||
for (const proj of projects) {
|
||||
const vulns = proj.vulnerabilities || [];
|
||||
vulnTotal += vulns.length;
|
||||
vulns.forEach(v => {
|
||||
findings.push({
|
||||
pkg: `${v.packageName}@${v.version}`,
|
||||
severity: v.severity,
|
||||
title: v.title,
|
||||
url: v.url,
|
||||
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.results.snyk.vulnCount = vulnTotal;
|
||||
this.results.snyk.findings = findings;
|
||||
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
|
||||
} catch (e) {
|
||||
console.error('Error parsing Snyk JSON:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async parseGitleaks() {
|
||||
const files = this.globFiles('.', 'results.sarif');
|
||||
if (files.length === 0) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||
let leaks = 0;
|
||||
let findings = [];
|
||||
for (const run of data.runs || []) {
|
||||
for (const res of run.results || []) {
|
||||
leaks++;
|
||||
findings.push({
|
||||
id: res.ruleId,
|
||||
message: res.message.text,
|
||||
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.results.gitleaks.leaksCount = leaks;
|
||||
this.results.gitleaks.findings = findings;
|
||||
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
|
||||
} catch (e) {
|
||||
console.error('Error parsing Gitleaks SARIF:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async parseTrivy() {
|
||||
const jsonPath = 'trivy_result.json';
|
||||
if (!fs.existsSync(jsonPath)) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
let misconfigs = 0;
|
||||
let findings = [];
|
||||
|
||||
(data.Results || []).forEach(res => {
|
||||
(res.Misconfigurations || []).forEach(m => {
|
||||
misconfigs++;
|
||||
findings.push({
|
||||
id: m.ID,
|
||||
severity: m.Severity,
|
||||
title: m.Title,
|
||||
message: m.Message,
|
||||
status: m.Status,
|
||||
target: res.Target
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
this.results.trivy.misconfigCount = misconfigs;
|
||||
this.results.trivy.findings = findings;
|
||||
if (misconfigs > 0) this.results.trivy.status = 'WARN';
|
||||
} catch (e) {
|
||||
console.error('Error parsing Trivy JSON:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
generateTable(type, t) {
|
||||
let files = [];
|
||||
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
|
||||
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
|
||||
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
|
||||
|
||||
if (files.length === 0) return `> ${t.noFiles}\n`;
|
||||
|
||||
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
|
||||
files.forEach(f => {
|
||||
const filename = path.basename(f);
|
||||
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
|
||||
});
|
||||
return table;
|
||||
}
|
||||
|
||||
// --- Renderers ---
|
||||
|
||||
generateMarkdown(localeKey) {
|
||||
const { codeql, snyk, gitleaks, coverage } = this.results;
|
||||
const t = this.locales[localeKey];
|
||||
|
||||
// Calculate Grade
|
||||
let grade = 'A+';
|
||||
let gradeColor = 'success';
|
||||
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
|
||||
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
|
||||
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
|
||||
|
||||
const badge = (label, value, color) => `}-${value}-${color}?style=for-the-badge)`;
|
||||
|
||||
let md = `# ${t.title}\n\n`;
|
||||
md += `${t.switcher}\n\n`;
|
||||
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
|
||||
md += `${t.important}\n\n`;
|
||||
|
||||
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
|
||||
md += `| :--- | :--- | :--- |\n`;
|
||||
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
|
||||
|
||||
md += `---\n\n## ${t.dashboard}\n\n`;
|
||||
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
|
||||
md += `| :--- | :--- | :--- |\n`;
|
||||
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
|
||||
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
|
||||
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
|
||||
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
|
||||
|
||||
md += `---\n\n## ${t.coverageTitle}\n\n`;
|
||||
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
|
||||
md += `| :--- | :---: | :---: |\n`;
|
||||
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
|
||||
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
|
||||
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
|
||||
|
||||
md += `---\n\n## ${t.detailedFindings}\n\n`;
|
||||
|
||||
// Gitleaks Section
|
||||
md += `### ${t.gitleaksTitle}\n`;
|
||||
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
|
||||
if (gitleaks.findings.length > 0) {
|
||||
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
|
||||
md += `| :--- | :--- | :--- |\n`;
|
||||
gitleaks.findings.forEach(f => {
|
||||
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
|
||||
});
|
||||
} else {
|
||||
md += `${t.gitleaksSafe}\n`;
|
||||
}
|
||||
|
||||
// Trivy Section
|
||||
md += `\n### ${t.trivyTitle}\n`;
|
||||
md += `${t.trivyDesc}\n\n`;
|
||||
if (this.results.trivy.findings.length > 0) {
|
||||
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
|
||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||
this.results.trivy.findings.forEach(f => {
|
||||
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
|
||||
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
|
||||
});
|
||||
} else {
|
||||
md += `${t.trivySafe}\n`;
|
||||
}
|
||||
|
||||
// Snyk Section
|
||||
md += `\n### ${t.snykTitle}\n`;
|
||||
if (snyk.findings.length > 0) {
|
||||
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
|
||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||
snyk.findings.forEach(f => {
|
||||
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
|
||||
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
|
||||
});
|
||||
} else {
|
||||
md += `${t.snykSafe}\n`;
|
||||
}
|
||||
|
||||
// CodeQL Section
|
||||
md += `\n### ${t.codeqlTitle}\n`;
|
||||
if (codeql.findings.length > 0) {
|
||||
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
|
||||
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
|
||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||
codeql.findings.forEach(f => {
|
||||
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
|
||||
const prefix = f.id.split('/')[0];
|
||||
const langMap = {
|
||||
'js': 'javascript',
|
||||
'actions': 'github-actions',
|
||||
'cpp': 'cpp',
|
||||
'cs': 'csharp',
|
||||
'go': 'go',
|
||||
'java': 'java',
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'swift': 'swift'
|
||||
};
|
||||
const langPath = langMap[prefix] || 'javascript';
|
||||
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
|
||||
});
|
||||
} else {
|
||||
md += `${t.codeqlSafe}\n`;
|
||||
}
|
||||
|
||||
// Audited Files List
|
||||
md += `\n### ${t.auditedList}\n`;
|
||||
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
|
||||
md += this.generateTable('actions', t);
|
||||
md += `\n</details>\n\n`;
|
||||
|
||||
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
|
||||
md += this.generateTable('js', t);
|
||||
md += `\n</details>\n\n`;
|
||||
|
||||
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
|
||||
md += this.generateTable('ts', t);
|
||||
md += `\n</details>\n\n`;
|
||||
|
||||
// Action Guide
|
||||
md += `--- \n\n## ${t.guideTitle}\n\n`;
|
||||
md += `${t.guideDesc}\n`;
|
||||
md += `${t.guideStep1}\n`;
|
||||
md += `${t.guideStep2}\n`;
|
||||
md += `${t.guideStep3}\n\n`;
|
||||
|
||||
md += `--- \n\n${t.footer}`;
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
getBadge(status) {
|
||||
if (status === 'PASS') return '';
|
||||
if (status === 'WARN' || status === 'INFO') return '';
|
||||
return '';
|
||||
}
|
||||
|
||||
globFiles(dir, ext) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(this.globFiles(fullPath, ext));
|
||||
} else if (file.endsWith(ext)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async run() {
|
||||
console.log('--- Security Report Generation Started ---');
|
||||
await this.parseCodeQL();
|
||||
await this.parseSnyk();
|
||||
await this.parseGitleaks();
|
||||
await this.parseTrivy();
|
||||
|
||||
for (const localeKey of Object.keys(this.locales)) {
|
||||
const locale = this.locales[localeKey];
|
||||
const markdown = this.generateMarkdown(localeKey);
|
||||
fs.writeFileSync(locale.filename, markdown);
|
||||
console.log(`Report generated successfully at ${locale.filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new SecurityReport().run().catch(err => {
|
||||
console.error('Report generation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: CodeQL Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
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@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,200 @@
|
||||
name: "Extra Security Scan"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
name: Gitleaks Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Gitleaks
|
||||
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_SUMMARY: "true"
|
||||
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
|
||||
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
|
||||
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||
|
||||
osv:
|
||||
name: OSV Dependency Scan
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
|
||||
with:
|
||||
scan-args: |-
|
||||
--recursive
|
||||
./
|
||||
upload-sarif: true
|
||||
fail-on-vuln: true
|
||||
|
||||
pnpm-audit:
|
||||
name: pnpm audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run pnpm audit
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f pnpm-lock.yaml ]; then
|
||||
echo "pnpm-lock.yaml not found, skip pnpm audit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
corepack enable
|
||||
corepack prepare pnpm@10 --activate
|
||||
pnpm audit --audit-level=high
|
||||
|
||||
semgrep:
|
||||
name: Semgrep CE Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Semgrep CE
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}:/src" \
|
||||
-w /src \
|
||||
semgrep/semgrep:latest \
|
||||
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
|
||||
|
||||
if [ ! -f semgrep.sarif ]; then
|
||||
cat > semgrep.sarif <<'EOF'
|
||||
{
|
||||
"version": "2.1.0",
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": {
|
||||
"name": "Semgrep",
|
||||
"informationUri": "https://semgrep.dev",
|
||||
"rules": []
|
||||
}
|
||||
},
|
||||
"results": []
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Upload Semgrep SARIF
|
||||
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
category: semgrep
|
||||
|
||||
actionlint:
|
||||
name: GitHub Actions Syntax Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${PWD}:/repo" \
|
||||
-w /repo \
|
||||
rhysd/actionlint:latest
|
||||
|
||||
zizmor:
|
||||
name: GitHub Actions Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
|
||||
with:
|
||||
persona: auditor
|
||||
min-severity: medium
|
||||
min-confidence: medium
|
||||
|
||||
scorecard:
|
||||
name: OpenSSF Scorecard
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
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@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
category: openssf-scorecard
|
||||
@@ -1,142 +0,0 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
env:
|
||||
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: env.ACT != 'true'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript-typescript, actions
|
||||
build-mode: none
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: env.ACT != 'true'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
upload: true
|
||||
output: sarif-results
|
||||
|
||||
- name: Install Gitleaks
|
||||
if: env.ACT != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
GITLEAKS_VERSION="8.28.0"
|
||||
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||
tar -xzf gitleaks.tar.gz gitleaks
|
||||
chmod +x gitleaks
|
||||
sudo mv gitleaks /usr/local/bin/gitleaks
|
||||
|
||||
- name: Secret Detection
|
||||
if: env.ACT != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
|
||||
|
||||
- name: Install Project Dependencies
|
||||
if: env.SECURITY_SNYK_TOKEN != ''
|
||||
env:
|
||||
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
|
||||
run: |
|
||||
echo "Preparing dependency lock files for security scanning..."
|
||||
if [ -z "$SECURITY_PACKAGE" ]; then
|
||||
echo "SECURITY_PACKAGE is empty, installing in root..."
|
||||
npm install --package-lock-only
|
||||
else
|
||||
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
|
||||
# Split by comma and install
|
||||
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
if [ -d "$pkg" ]; then
|
||||
echo "Installing in "$pkg"..."
|
||||
npm install --prefix "$pkg" --package-lock-only
|
||||
else
|
||||
echo "Warning: Directory $pkg not found, skipping."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Dependency Scan
|
||||
id: snyk
|
||||
if: env.SECURITY_SNYK_TOKEN != ''
|
||||
continue-on-error: true
|
||||
run: |
|
||||
npm install -g snyk
|
||||
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
|
||||
env:
|
||||
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||
|
||||
- name: Check for Dockerfile
|
||||
id: check_docker
|
||||
run: |
|
||||
if [ -f "Dockerfile" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Container Security Scan (Trivy)
|
||||
if: steps.check_docker.outputs.exists == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION="0.56.1"
|
||||
echo "Installing Trivy $VERSION..."
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
|
||||
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
|
||||
|
||||
- name: Generate Security Report
|
||||
run: |
|
||||
# Gitleaks typically produces results.sarif if configured or by default in some versions
|
||||
# We'll ensure it exists for our reporter
|
||||
node .github/scripts/security.cjs
|
||||
|
||||
# Also append to step summary for immediate visibility in GHA UI
|
||||
cat security-report.md >> $GITHUB_STEP_SUMMARY
|
||||
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
|
||||
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload Gitleaks Results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: gitleaks
|
||||
|
||||
- name: Upload Security Report Artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: security-report
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
security-report.md
|
||||
security-report-cn.md
|
||||
snyk_result.txt
|
||||
snyk_result.json
|
||||
trivy_result.json
|
||||
results.sarif
|
||||
sarif-results/*.sarif
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Sync Bitwarden global domains
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bitwarden_ref:
|
||||
description: "bitwarden/server ref to sync"
|
||||
required: false
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-global-domains:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
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@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
with:
|
||||
branch: chore/sync-bitwarden-global-domains
|
||||
delete-branch: true
|
||||
title: "chore: sync Bitwarden global domain rules"
|
||||
commit-message: "chore: sync Bitwarden global domain rules"
|
||||
body: |
|
||||
Automated sync from bitwarden/server.
|
||||
|
||||
This PR only updates:
|
||||
- `src/static/global_domains.bitwarden.json`
|
||||
- `src/static/global_domains.bitwarden.meta.json`
|
||||
|
||||
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||
add-paths: |
|
||||
src/static/global_domains.bitwarden.json
|
||||
src/static/global_domains.bitwarden.meta.json
|
||||
@@ -1,143 +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" >> $GITHUB_OUTPUT
|
||||
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
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" >> $GITHUB_OUTPUT
|
||||
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" >> $GITHUB_OUTPUT
|
||||
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" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
+22
-2
@@ -18,6 +18,7 @@ build/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
docs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -26,7 +27,7 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
.vite-tailwind.err
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
@@ -40,7 +41,26 @@ npm-debug.log*
|
||||
|
||||
tmp/
|
||||
.tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden.wiki/
|
||||
nodewarden-wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
.claude/
|
||||
NodeWarden-compat/
|
||||
.codex-upstream/
|
||||
.codex-upstream/bitwarden-server/
|
||||
.codex-upstream/bitwarden-clients/
|
||||
.codex-upstream/bitwarden-web/
|
||||
.codex-upstream/bitwarden-browser/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
security-audits/
|
||||
.mcp.json
|
||||
opencode.jsonc
|
||||
.cursor/
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Contributing to NodeWarden
|
||||
|
||||
Thanks for taking the time to improve NodeWarden.
|
||||
|
||||
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
|
||||
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
|
||||
Small changes can affect official clients, backups, migrations, or locale files,
|
||||
so please keep changes focused and check the related parts of the project.
|
||||
|
||||
## Before Opening an Issue
|
||||
|
||||
For bug reports, include enough detail for someone else to reproduce the problem:
|
||||
|
||||
- The client or browser you used.
|
||||
- The page, API route, or action that failed.
|
||||
- Screenshots, logs, or the exact error message.
|
||||
- Whether the problem happened after sync, import, export, restore, upgrade, or
|
||||
a fresh deployment.
|
||||
|
||||
Please do not report NodeWarden-specific problems to the official Bitwarden
|
||||
team. This project is independent from Bitwarden.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Keep pull requests small enough to review. A good PR should explain:
|
||||
|
||||
- What changed and why.
|
||||
- What user-facing behavior changed.
|
||||
- Which related areas were checked.
|
||||
- Which commands were run before submitting.
|
||||
|
||||
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
|
||||
needed before the real fix, mention that clearly in the PR.
|
||||
|
||||
## Areas That Need Extra Care
|
||||
|
||||
Some parts of the codebase are deliberately connected. When changing one of
|
||||
these areas, check the related files before calling the work complete.
|
||||
|
||||
### Database Changes
|
||||
|
||||
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
|
||||
lives in `migrations/0001_init.sql`.
|
||||
|
||||
If you add or change a table, column, or index:
|
||||
|
||||
- Update both schema files.
|
||||
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
|
||||
- Decide whether the data should be included in instance backup.
|
||||
|
||||
### Backup And Restore
|
||||
|
||||
Backup export and restore are whitelist-based. This protects old backups from
|
||||
breaking when fields are removed and prevents transient or secret runtime data
|
||||
from being exported by accident.
|
||||
|
||||
When adding persistent data, check:
|
||||
|
||||
- `src/services/backup-archive.ts`
|
||||
- `src/services/backup-import.ts`
|
||||
- `webapp/src/lib/api/backup.ts`
|
||||
|
||||
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
|
||||
retired sensitive fields such as `users.api_key`.
|
||||
|
||||
### Secrets And Provider Settings
|
||||
|
||||
Provider credentials must not be stored or exported as plain config JSON. Follow
|
||||
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
|
||||
document a replacement design before changing it.
|
||||
|
||||
### Bitwarden Client Compatibility
|
||||
|
||||
Official Bitwarden clients may send or expect fields that are not used directly
|
||||
by the web vault. Cipher and sync changes should preserve unknown client fields
|
||||
unless they are known-invalid or server-owned.
|
||||
|
||||
Check these files when changing vault item shape or sync behavior:
|
||||
|
||||
- `src/handlers/ciphers.ts`
|
||||
- `src/handlers/sync.ts`
|
||||
- `src/services/storage-cipher-repo.ts`
|
||||
|
||||
### Domain Rules
|
||||
|
||||
Equivalent-domain settings store both client/UI rule state and derived active
|
||||
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
|
||||
`excluded_global_equivalent_domains` as duplicates without a migration and
|
||||
compatibility plan.
|
||||
|
||||
### Accounts And Passwords
|
||||
|
||||
`users.master_password_hash` is for server-side login verification. It is not the
|
||||
vault decryption key. Password changes, key material, `securityStamp`, and
|
||||
refresh-token revocation must stay aligned.
|
||||
|
||||
Password hints are reminders, not recovery secrets. They must never contain the
|
||||
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||
vault.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||
text, keep every locale in sync and run the validation script.
|
||||
|
||||
For new locales, update:
|
||||
|
||||
- `webapp/src/lib/i18n.ts`
|
||||
- `webapp/src/lib/i18n/locales/*`
|
||||
- `scripts/i18n-utils.cjs`
|
||||
|
||||
## Recommended Checks
|
||||
|
||||
For most backend or shared changes:
|
||||
|
||||
```sh
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
For webapp text or locale changes:
|
||||
|
||||
```sh
|
||||
npm run i18n:validate
|
||||
npx tsc -p webapp/tsconfig.json --noEmit
|
||||
npm run build
|
||||
```
|
||||
|
||||
For documentation-only changes:
|
||||
|
||||
```sh
|
||||
git diff --check
|
||||
```
|
||||
@@ -3,89 +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="./RELEASE_NOTES.md">更新日志</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
|
||||
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
|
||||
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||
</p>
|
||||
|
||||
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
|
||||
<p align="center">
|
||||
<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界面** |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||
| 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 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
|
||||
|
||||
- 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).
|
||||
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
|
||||
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 `Continue with GitHub`
|
||||
4. 选择你刚刚 Fork 的仓库
|
||||
5. 保持默认配置继续部署
|
||||
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
7. 等部署完成后,打开生成的 Workers 域名
|
||||
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||
|
||||
> [!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
|
||||
@@ -94,65 +105,31 @@ cd NodeWarden
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
# Default: R2 mode
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
# Optional: KV mode
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
# Local development
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 云端备份说明
|
||||
|
||||
- 远程备份支持 **WebDAV** 与 **E3**
|
||||
- 勾选“包含附件”后:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
-153
@@ -1,153 +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="./RELEASE_NOTES.md">Release Notes</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
|
||||
|
||||
</p>
|
||||
|
||||
中文说明:<a href="./README.md"><code>README.md</code></a>
|
||||
|
||||
> **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** |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||
| 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** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||
| 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 | Currently only user-level TOTP |
|
||||
| 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloud Backup Notes
|
||||
|
||||
- Remote backup supports **WebDAV** and **E3**
|
||||
- When `Include attachments` is enabled:
|
||||
- the ZIP still contains only `db.json` and `manifest.json`
|
||||
- actual attachment files are stored separately under `attachments/`
|
||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||
- During remote restore:
|
||||
- required attachment files are loaded from `attachments/` on demand
|
||||
- missing attachments are skipped safely
|
||||
- skipped attachments do not leave broken rows in the restored database
|
||||
|
||||
---
|
||||
|
||||
## Import / Export
|
||||
|
||||
Current supported import sources include:
|
||||
|
||||
- Bitwarden JSON
|
||||
- Bitwarden CSV
|
||||
- Bitwarden vault + attachments ZIP
|
||||
- NodeWarden JSON
|
||||
- Multiple browser / password-manager formats available in the web import selector
|
||||
|
||||
Current supported export formats include:
|
||||
|
||||
- Bitwarden JSON
|
||||
- Bitwarden encrypted JSON
|
||||
- ZIP export with attachments
|
||||
- NodeWarden JSON variants
|
||||
- Full manual instance export from the backup center
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||
<a href="https://nodewarden.app">官方wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
|
||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 Continue with GitHub 并选择你的仓库
|
||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
5. 等部署完成后,打开生成的 Workers 域名
|
||||
|
||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||
|
||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||
|
||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for helping keep NodeWarden safe.
|
||||
|
||||
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
|
||||
|
||||
Use GitHub Private Vulnerability Reporting instead:
|
||||
|
||||
1. Open the NodeWarden repository on GitHub.
|
||||
2. Go to **Security and quality**.
|
||||
3. Click **Report a vulnerability**.
|
||||
4. Submit the report privately.
|
||||
|
||||
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
|
||||
|
||||
## What to Include
|
||||
|
||||
Please include as much detail as possible:
|
||||
|
||||
* A clear description of the vulnerability.
|
||||
* Steps to reproduce.
|
||||
* Affected version, commit, or deployment method.
|
||||
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
|
||||
* Expected behavior and actual behavior.
|
||||
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
|
||||
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
|
||||
|
||||
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
|
||||
|
||||
## Scope
|
||||
|
||||
Security reports are welcome for issues affecting NodeWarden itself, including:
|
||||
|
||||
* Authentication and session handling.
|
||||
* User authorization and cross-user access.
|
||||
* Vault data, cipher sync, attachments, and Send.
|
||||
* Import, export, backup, and restore.
|
||||
* Passkey, WebAuthn, and two-factor authentication.
|
||||
* Secret handling and provider credentials.
|
||||
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are usually out of scope:
|
||||
|
||||
* Issues only affecting third-party services or user infrastructure.
|
||||
* Misconfigured personal deployments not caused by NodeWarden defaults.
|
||||
* Social engineering or phishing.
|
||||
* Denial-of-service testing.
|
||||
* Scanner-only reports without a practical exploit path.
|
||||
* Reports that only mention outdated dependencies without showing real impact.
|
||||
|
||||
## Response
|
||||
|
||||
NodeWarden is maintained on a best-effort basis.
|
||||
|
||||
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
|
||||
|
||||
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are generally provided for the latest release and the latest code on the default branch.
|
||||
|
||||
| Version | Supported |
|
||||
| -------------- | ---------------------- |
|
||||
| Latest release | Yes |
|
||||
| `main` branch | Yes |
|
||||
| Older releases | Best effort |
|
||||
| Modified forks | Not directly supported |
|
||||
|
||||
## Rewards
|
||||
|
||||
NodeWarden does not currently operate a paid bug bounty program.
|
||||
+103
-1
@@ -1,8 +1,14 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- IMPORTANT:
|
||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||
-- This is the initial D1 schema. Keep it in sync with
|
||||
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
|
||||
-- Any new table/column/index must be added to both places together.
|
||||
--
|
||||
-- WHEN CHANGING THIS:
|
||||
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
|
||||
-- - If the new table stores persistent data, update backup export/import.
|
||||
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -33,6 +39,15 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Per-user sync revision date
|
||||
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
@@ -115,6 +130,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
device_identifier TEXT,
|
||||
device_session_stamp TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
@@ -137,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_user_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'system',
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
metadata TEXT,
|
||||
@@ -145,6 +164,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -155,6 +176,10 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
push_uuid TEXT,
|
||||
push_token TEXT,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
banned_at TEXT,
|
||||
device_note TEXT,
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -164,6 +189,34 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
organization_id TEXT,
|
||||
type INTEGER NOT NULL,
|
||||
request_device_identifier TEXT NOT NULL,
|
||||
request_device_type INTEGER NOT NULL,
|
||||
request_ip_address TEXT,
|
||||
request_country_name TEXT,
|
||||
response_device_identifier TEXT,
|
||||
access_code TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key TEXT,
|
||||
master_password_hash TEXT,
|
||||
approved INTEGER,
|
||||
creation_date TEXT NOT NULL,
|
||||
response_date TEXT,
|
||||
authentication_date TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
|
||||
ON auth_requests(user_id, creation_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
|
||||
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
|
||||
ON auth_requests(user_id, request_device_identifier, creation_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
@@ -175,6 +228,55 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS totp_login_replays (
|
||||
user_id TEXT NOT NULL,
|
||||
time_counter INTEGER NOT NULL,
|
||||
consumed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, time_counter),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
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,
|
||||
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
|
||||
);
|
||||
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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
||||
ON webauthn_challenges(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
|
||||
ON webauthn_challenges(user_id, scope);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
ip TEXT PRIMARY KEY,
|
||||
|
||||
Generated
+1167
-707
File diff suppressed because it is too large
Load Diff
+28
-22
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.5.1",
|
||||
"version": "1.7.3",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -12,10 +12,11 @@
|
||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||
"build": "vite build --config webapp/vite.config.ts",
|
||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -41,30 +42,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"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",
|
||||
"@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": "^7.3.1",
|
||||
"wrangler": "^4.71.0"
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@noble/hashes": "^2.0.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Make `deploy:kv` idempotent across repeated builds.
|
||||
*
|
||||
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
|
||||
* by name. The template ships without an id so fresh accounts can provision one
|
||||
* on first deploy. In non-interactive builds, wrangler may try to create the
|
||||
* same namespace again on later builds and fail with code 10014.
|
||||
*/
|
||||
const { execSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
|
||||
const BINDING = 'ATTACHMENTS_KV';
|
||||
|
||||
const wrangler = (args) =>
|
||||
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
|
||||
function bindingBlockHasId(toml) {
|
||||
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
|
||||
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
|
||||
return block ? /^\s*id\s*=/m.test(block) : false;
|
||||
}
|
||||
|
||||
function expectedTitle(toml) {
|
||||
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
|
||||
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
function resolveId(title) {
|
||||
const list = JSON.parse(wrangler('kv namespace list'));
|
||||
const hit =
|
||||
list.find((namespace) => namespace.title === title) ||
|
||||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
|
||||
if (hit) {
|
||||
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
|
||||
return hit.id;
|
||||
}
|
||||
|
||||
const out = wrangler(`kv namespace create "${title}"`);
|
||||
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
|
||||
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
|
||||
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let toml = fs.readFileSync(CONFIG, 'utf8');
|
||||
if (bindingBlockHasId(toml)) {
|
||||
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = resolveId(expectedTitle(toml));
|
||||
toml = toml.replace(
|
||||
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
|
||||
`$1\nid = "${id}"`
|
||||
);
|
||||
fs.writeFileSync(CONFIG, toml);
|
||||
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -2,6 +2,9 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
// CONTRACT:
|
||||
// This list is the script-side locale source of truth. Keep it in sync with
|
||||
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
|
||||
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||
|
||||
const localeFiles = [
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||
|
||||
// CONTRACT:
|
||||
// This is the authoritative locale consistency gate. It checks key parity,
|
||||
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||
// user-facing text or locale-file change.
|
||||
const locales = Object.fromEntries(
|
||||
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||
);
|
||||
@@ -18,6 +22,17 @@ const intentionallyEnglishKeys = new Set([
|
||||
'txt_dash',
|
||||
'txt_text_3',
|
||||
]);
|
||||
const intentionallyEnglishPrefixes = [
|
||||
'txt_log_action_',
|
||||
'txt_log_meta_',
|
||||
'txt_log_reason_',
|
||||
'txt_log_target_type_',
|
||||
'txt_log_trigger_',
|
||||
];
|
||||
|
||||
function isIntentionallyEnglishKey(key) {
|
||||
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
for (const [locale, table] of Object.entries(locales)) {
|
||||
const keys = Object.keys(table).sort();
|
||||
@@ -36,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) {
|
||||
}
|
||||
|
||||
if (locale !== 'en') {
|
||||
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
|
||||
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
|
||||
if (sameAsEnglish.length > 40) {
|
||||
errors.push({
|
||||
locale,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_REF = 'main';
|
||||
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--ref' && argv[i + 1]) {
|
||||
args.ref = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (arg.startsWith('--ref=')) {
|
||||
args.ref = arg.slice('--ref='.length);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function rawUrl(ref, filePath) {
|
||||
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'NodeWarden global domains sync',
|
||||
Accept: 'text/plain',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function parseEnumTypes(source) {
|
||||
const map = new Map();
|
||||
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||
if (!enumMatch) {
|
||||
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||
}
|
||||
|
||||
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||
let match;
|
||||
while ((match = entryRe.exec(body)) !== null) {
|
||||
map.set(match[1], Number(match[2]));
|
||||
}
|
||||
|
||||
if (!map.size) {
|
||||
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function parseStringList(source) {
|
||||
const domains = [];
|
||||
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||
let match;
|
||||
while ((match = stringRe.exec(source)) !== null) {
|
||||
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||
}
|
||||
return Array.from(new Set(domains.filter(Boolean)));
|
||||
}
|
||||
|
||||
function parseGlobalDomains(source, enumTypes) {
|
||||
const out = [];
|
||||
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||
let match;
|
||||
while ((match = addRe.exec(source)) !== null) {
|
||||
const name = match[1];
|
||||
const type = enumTypes.get(name);
|
||||
if (!Number.isInteger(type)) {
|
||||
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||
}
|
||||
|
||||
const domains = parseStringList(match[2]);
|
||||
if (domains.length < 2) {
|
||||
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||
}
|
||||
|
||||
out.push({
|
||||
type,
|
||||
domains,
|
||||
excluded: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!out.length) {
|
||||
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatRulesJson(rules) {
|
||||
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
|
||||
}
|
||||
|
||||
function formatMetaJson(meta) {
|
||||
return JSON.stringify(meta, null, 2);
|
||||
}
|
||||
|
||||
const { ref } = parseArgs(process.argv.slice(2));
|
||||
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||
|
||||
const [enumSource, staticStoreSource] = await Promise.all([
|
||||
fetchText(enumUrl),
|
||||
fetchText(staticStoreUrl),
|
||||
]);
|
||||
|
||||
const enumTypes = parseEnumTypes(enumSource);
|
||||
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||
const rulesJson = formatRulesJson(rules);
|
||||
|
||||
async function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingRules = await readJsonFile(OUT_FILE);
|
||||
const existingMeta = await readJsonFile(META_FILE);
|
||||
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||
const unchangedRef = existingMeta?.ref === ref;
|
||||
|
||||
const meta = {
|
||||
source: 'https://github.com/bitwarden/server',
|
||||
ref,
|
||||
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||
? existingMeta.generatedAt
|
||||
: new Date().toISOString(),
|
||||
rulesCount: rules.length,
|
||||
domainsCount,
|
||||
sourceFiles: [
|
||||
ENUM_PATH,
|
||||
STATIC_STORE_PATH,
|
||||
],
|
||||
sourceUrls: [
|
||||
enumUrl,
|
||||
staticStoreUrl,
|
||||
],
|
||||
};
|
||||
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
|
||||
|
||||
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.5.1';
|
||||
export const APP_VERSION = '1.7.3';
|
||||
|
||||
+15
-3
@@ -1,15 +1,26 @@
|
||||
// Shared backup settings types used by both Worker and webapp code.
|
||||
//
|
||||
// CONTRACT:
|
||||
// Keep this file serializable and provider-neutral. Runtime state is operational
|
||||
// metadata; destination fields can contain provider credentials and must be
|
||||
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
|
||||
// User-facing provider names should use canonical values here. Legacy aliases
|
||||
// belong in backend normalization, not in this shared type.
|
||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
|
||||
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
export type BackupDestinationType = 's3' | 'webdav';
|
||||
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||
|
||||
export interface S3BackupDestination {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
addressingStyle: S3BackupAddressingStyle;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
@@ -95,17 +106,18 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
return {
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
addressingStyle: 'path-style',
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||
'ac.cn',
|
||||
'com.cn',
|
||||
'edu.cn',
|
||||
'gov.cn',
|
||||
'net.cn',
|
||||
'org.cn',
|
||||
'ah.cn',
|
||||
'bj.cn',
|
||||
'cq.cn',
|
||||
'fj.cn',
|
||||
'gd.cn',
|
||||
'gs.cn',
|
||||
'gx.cn',
|
||||
'gz.cn',
|
||||
'ha.cn',
|
||||
'hb.cn',
|
||||
'he.cn',
|
||||
'hi.cn',
|
||||
'hk.cn',
|
||||
'hl.cn',
|
||||
'hn.cn',
|
||||
'jl.cn',
|
||||
'js.cn',
|
||||
'jx.cn',
|
||||
'ln.cn',
|
||||
'mo.cn',
|
||||
'nm.cn',
|
||||
'nx.cn',
|
||||
'qh.cn',
|
||||
'sc.cn',
|
||||
'sd.cn',
|
||||
'sh.cn',
|
||||
'sn.cn',
|
||||
'sx.cn',
|
||||
'tj.cn',
|
||||
'tw.cn',
|
||||
'xj.cn',
|
||||
'xz.cn',
|
||||
'yn.cn',
|
||||
'zj.cn',
|
||||
'co.uk',
|
||||
'org.uk',
|
||||
'net.uk',
|
||||
'ac.uk',
|
||||
'gov.uk',
|
||||
'com.au',
|
||||
'net.au',
|
||||
'org.au',
|
||||
'edu.au',
|
||||
'gov.au',
|
||||
'co.nz',
|
||||
'org.nz',
|
||||
'net.nz',
|
||||
'com.br',
|
||||
'com.mx',
|
||||
'com.ar',
|
||||
'com.tr',
|
||||
'com.sg',
|
||||
'com.my',
|
||||
'com.hk',
|
||||
'com.tw',
|
||||
'co.jp',
|
||||
'ne.jp',
|
||||
'or.jp',
|
||||
'co.kr',
|
||||
'or.kr',
|
||||
'co.in',
|
||||
'firm.in',
|
||||
'net.in',
|
||||
'org.in',
|
||||
'co.id',
|
||||
'or.id',
|
||||
'web.id',
|
||||
'co.il',
|
||||
'org.il',
|
||||
'co.za',
|
||||
'com.sa',
|
||||
'com.ph',
|
||||
'com.vn',
|
||||
'com.pk',
|
||||
'com.bd',
|
||||
'com.ng',
|
||||
'github.io',
|
||||
'pages.dev',
|
||||
'workers.dev',
|
||||
'cloudflareaccess.com',
|
||||
'vercel.app',
|
||||
'netlify.app',
|
||||
'web.app',
|
||||
'firebaseapp.com',
|
||||
'herokuapp.com',
|
||||
'fly.dev',
|
||||
'railway.app',
|
||||
'render.com',
|
||||
'onrender.com',
|
||||
]);
|
||||
|
||||
function extractHost(input: string): string {
|
||||
let raw = input.trim().toLowerCase();
|
||||
if (!raw) return '';
|
||||
raw = raw.replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
const parsed = new URL(candidate);
|
||||
raw = parsed.hostname;
|
||||
} catch {
|
||||
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||
const atIndex = raw.lastIndexOf('@');
|
||||
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||
if (raw.startsWith('[')) return '';
|
||||
const colonIndex = raw.lastIndexOf(':');
|
||||
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/^\*+\./, '')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
function isValidHost(host: string): boolean {
|
||||
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||
return host.split('.').every((label) => (
|
||||
label.length > 0
|
||||
&& label.length <= 63
|
||||
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||
));
|
||||
}
|
||||
|
||||
export function normalizeEquivalentDomain(value: unknown): string {
|
||||
const host = extractHost(String(value || ''));
|
||||
if (!isValidHost(host)) return '';
|
||||
|
||||
const labels = host.split('.');
|
||||
for (let index = 0; index < labels.length; index += 1) {
|
||||
const suffix = labels.slice(index).join('.');
|
||||
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||
if (index === 0) return '';
|
||||
return labels.slice(index - 1).join('.');
|
||||
}
|
||||
|
||||
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||
}
|
||||
|
||||
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||
return !!normalizeEquivalentDomain(value);
|
||||
}
|
||||
+14
-3
@@ -5,10 +5,10 @@
|
||||
accessTokenTtlSeconds: 7200,
|
||||
// Refresh token lifetime in milliseconds.
|
||||
// 刷新令牌有效期(毫秒)。
|
||||
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
|
||||
// Grace window for previous refresh token after rotation (ms).
|
||||
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
||||
refreshTokenOverlapGraceMs: 60 * 1000,
|
||||
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
|
||||
// Refresh token random byte length.
|
||||
// 刷新令牌随机字节长度。
|
||||
refreshTokenRandomBytes: 32,
|
||||
@@ -44,6 +44,9 @@
|
||||
// Public read-only request budget per IP per minute.
|
||||
// 公开只读接口每 IP 每分钟请求配额。
|
||||
publicReadRequestsPerMinute: 120,
|
||||
// Public website icon proxy budget per IP per minute.
|
||||
// 公开网站图标代理每 IP 每分钟请求配额。
|
||||
publicIconRequestsPerMinute: 500,
|
||||
// Sensitive public/auth request budget per IP per minute.
|
||||
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||
sensitivePublicRequestsPerMinute: 30,
|
||||
@@ -59,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,
|
||||
@@ -145,6 +151,11 @@
|
||||
compatibility: {
|
||||
// Single source of truth for /config.version and /api/version.
|
||||
// /config.version 与 /api/version 的统一版本号来源。
|
||||
bitwardenServerVersion: '2026.1.0',
|
||||
bitwardenServerVersion: '2026.4.1',
|
||||
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
|
||||
cipherKeyEncryptionFeatureEnabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import type { Env } from '../types';
|
||||
import type { BackupDestinationRecord } from '../services/backup-config';
|
||||
import {
|
||||
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||
requireBackupDestination,
|
||||
hasBackupSlotBetween,
|
||||
isBackupDueNow,
|
||||
loadBackupSettings,
|
||||
} from '../services/backup-config';
|
||||
import {
|
||||
createRemoteBackupTransferSession,
|
||||
downloadRemoteBackupFile,
|
||||
ensureRemoteRestoreCandidate,
|
||||
} from '../services/backup-uploader';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
|
||||
import {
|
||||
executeConfiguredBackup,
|
||||
importAndAuditRemoteBackupFile,
|
||||
} from '../handlers/backup';
|
||||
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
|
||||
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
|
||||
|
||||
interface BackupJobState {
|
||||
token: string;
|
||||
reason: string;
|
||||
acquiredAt: string;
|
||||
touchedAt: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentChunkRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
attachments: Array<{
|
||||
blobName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobName?: string | null;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentBatchDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobNames?: string[] | null;
|
||||
}
|
||||
|
||||
interface ConfiguredBackupRunRequest {
|
||||
actorUserId?: string | null;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
trigger?: 'manual' | 'scheduled';
|
||||
}
|
||||
|
||||
interface RemoteBackupRestoreRequest {
|
||||
actorUserId?: string | null;
|
||||
allowChecksumMismatch?: boolean;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
path?: string | null;
|
||||
replaceExisting?: boolean;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
}
|
||||
|
||||
function badRequest(message: string, status: number = 400): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class BackupTransferRunner {
|
||||
private lastHeartbeatAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly state: DurableObjectState,
|
||||
private readonly env: Env
|
||||
) {
|
||||
}
|
||||
|
||||
private async acquireJob(reason: string): Promise<string | null> {
|
||||
const nowMs = Date.now();
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: nowIso,
|
||||
touchedAt: nowIso,
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
this.lastHeartbeatAt = 0;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async touchJob(token: string): Promise<void> {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
|
||||
this.lastHeartbeatAt = nowMs;
|
||||
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token !== token) return;
|
||||
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
...current,
|
||||
touchedAt: new Date(nowMs).toISOString(),
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseJob(token: string): Promise<void> {
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token === token) {
|
||||
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async runConfiguredBackup(request: Request): Promise<Response> {
|
||||
let body: ConfiguredBackupRunRequest;
|
||||
try {
|
||||
body = await request.json<ConfiguredBackupRunRequest>();
|
||||
} catch {
|
||||
return badRequest('Backup run payload is invalid');
|
||||
}
|
||||
|
||||
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (trigger === 'manual' && !actorUserId) {
|
||||
return badRequest('Manual backup run requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const progress = actorUserId
|
||||
? async (event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
event,
|
||||
String(body.targetDeviceIdentifier || '').trim() || null
|
||||
);
|
||||
}
|
||||
: null;
|
||||
|
||||
const result = await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
trigger,
|
||||
body.destinationId || null,
|
||||
() => this.touchJob(token),
|
||||
progress,
|
||||
body.auditMetadata || null
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
object: 'backup-runner-result',
|
||||
result,
|
||||
settings,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async runScheduledBackups(): Promise<Response> {
|
||||
const token = await this.acquireJob('scheduled');
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
let scanStartMs = Date.now();
|
||||
|
||||
while (true) {
|
||||
await this.touchJob(token);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const now = new Date();
|
||||
const dueDestinations = settings.destinations.filter((destination) =>
|
||||
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||
);
|
||||
|
||||
if (!dueDestinations.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await this.touchJob(token);
|
||||
try {
|
||||
await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
null,
|
||||
'scheduled',
|
||||
destination.id,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
completed += 1;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
destinationId: destination.id,
|
||||
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
completed,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreRemoteBackup(request: Request): Promise<Response> {
|
||||
let body: RemoteBackupRestoreRequest;
|
||||
try {
|
||||
body = await request.json<RemoteBackupRestoreRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote restore payload is invalid');
|
||||
}
|
||||
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (!actorUserId) {
|
||||
return badRequest('Remote restore requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`restore:${actorUserId}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup or restore run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
|
||||
const replaceExisting = !!body.replaceExisting;
|
||||
|
||||
await notifyUserBackupRestoreProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
source: 'remote',
|
||||
step: 'remote_fetch_archive',
|
||||
fileName: restoreFileNameFromPath,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||
replaceExisting,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||
return badRequest('Remote backup file checksum does not match its filename');
|
||||
}
|
||||
|
||||
const result = await importAndAuditRemoteBackupFile(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
remoteFile,
|
||||
destination,
|
||||
path,
|
||||
replaceExisting,
|
||||
!checksumOk,
|
||||
body.auditMetadata || null,
|
||||
targetDeviceIdentifier,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(result.result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'POST') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-configured-backup') {
|
||||
return this.runConfiguredBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-scheduled-backups') {
|
||||
return this.runScheduledBackups();
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/restore-remote-backup') {
|
||||
return this.restoreRemoteBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment') {
|
||||
let body: RemoteAttachmentDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const blobName = String(body?.blobName || '').trim();
|
||||
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) {
|
||||
return badRequest('Remote attachment not found', 404);
|
||||
}
|
||||
return new Response(file.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType || 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment-batch') {
|
||||
let body: RemoteAttachmentBatchDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
const blobNames = Array.from(new Set(
|
||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||
.map((blobName) => String(blobName || '').trim())
|
||||
.filter(isSafeBackupAttachmentBlobName)
|
||||
));
|
||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const entries: Array<{ blobName: string; path: string }> = [];
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (let i = 0; i < blobNames.length; i += 1) {
|
||||
const blobName = blobNames[i];
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) continue;
|
||||
const path = `files/${i}.bin`;
|
||||
entries.push({ blobName, path });
|
||||
files[path] = file.bytes;
|
||||
}
|
||||
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
|
||||
|
||||
return new Response(zipSync(files), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname !== '/internal/upload-attachment-chunk') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
let body: RemoteAttachmentChunkRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentChunkRequest>();
|
||||
} catch {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
if (!body?.destination || !Array.isArray(body.attachments)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const remoteSession = createRemoteBackupTransferSession(body.destination);
|
||||
let uploaded = 0;
|
||||
|
||||
for (const attachment of body.attachments) {
|
||||
const blobName = String(attachment?.blobName || '').trim();
|
||||
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const object = await getBlobObject(this.env, blobName);
|
||||
if (!object) {
|
||||
return badRequest(`Attachment blob missing for ${blobName}`, 409);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
uploaded += 1;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
uploaded,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
import { notifyMobilePush } from '../services/push-relay';
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
type HubKind = 'user' | 'anonymous-auth-request';
|
||||
|
||||
interface WsAttachment {
|
||||
userId: string;
|
||||
kind: HubKind;
|
||||
userId: string | null;
|
||||
authRequestId: string | null;
|
||||
handshakeComplete: boolean;
|
||||
protocol: HubProtocol;
|
||||
deviceIdentifier: string | null;
|
||||
@@ -137,11 +152,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
||||
function buildSignalRJsonInvocation(
|
||||
updateType: number,
|
||||
payload: Record<string, unknown>,
|
||||
contextId: string | null
|
||||
contextId: string | null,
|
||||
target: string = 'ReceiveMessage'
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
type: 1,
|
||||
target: 'ReceiveMessage',
|
||||
target,
|
||||
arguments: [
|
||||
{
|
||||
ContextId: contextId,
|
||||
@@ -155,15 +171,16 @@ function buildSignalRJsonInvocation(
|
||||
function buildSignalRMessagePackInvocation(
|
||||
updateType: number,
|
||||
messagePayload: Record<string, unknown>,
|
||||
contextId: string | null
|
||||
contextId: string | null,
|
||||
target: string = 'ReceiveMessage'
|
||||
): Uint8Array {
|
||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||
// [type, headers, invocationId, target, arguments]
|
||||
// [type, headers, invocationId, target, arguments, streamIds]
|
||||
const encodedPayload = encodeMsgPack([
|
||||
1,
|
||||
{},
|
||||
null,
|
||||
'ReceiveMessage',
|
||||
target,
|
||||
[
|
||||
{
|
||||
ContextId: contextId,
|
||||
@@ -171,6 +188,7 @@ function buildSignalRMessagePackInvocation(
|
||||
Payload: messagePayload,
|
||||
},
|
||||
],
|
||||
[],
|
||||
]);
|
||||
return frameSignalRBinary(encodedPayload);
|
||||
}
|
||||
@@ -201,7 +219,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||
const contextId = String(body?.contextId || '').trim() || null;
|
||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||
const rawUpdateType = body?.updateType;
|
||||
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
|
||||
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||
const payload = body?.payload && typeof body.payload === 'object'
|
||||
? body.payload
|
||||
@@ -213,6 +233,20 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
userId?: string;
|
||||
authRequestId?: string;
|
||||
contextId?: string | null;
|
||||
} | null;
|
||||
const userId = String(body?.userId || '').trim();
|
||||
const authRequestId = String(body?.authRequestId || '').trim();
|
||||
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
|
||||
|
||||
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
||||
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
||||
status: 200,
|
||||
@@ -222,7 +256,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname !== '/notifications/hub') {
|
||||
if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
@@ -232,8 +266,13 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
|
||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
|
||||
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
|
||||
|
||||
if (!requestUserId) {
|
||||
if (!isAnonymousAuthRequestHub && !requestUserId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
@@ -248,7 +287,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
this.ctx.acceptWebSocket(server, tags);
|
||||
|
||||
server.serializeAttachment({
|
||||
userId: requestUserId,
|
||||
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
|
||||
userId: isAnonymousAuthRequestHub ? null : requestUserId,
|
||||
authRequestId: requestAuthRequestId,
|
||||
handshakeComplete: false,
|
||||
protocol: 'messagepack',
|
||||
deviceIdentifier: requestDeviceIdentifier,
|
||||
@@ -274,7 +315,6 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
attachment.handshakeComplete = true;
|
||||
ws.serializeAttachment(attachment);
|
||||
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
return;
|
||||
} catch {
|
||||
// Ignore malformed pre-handshake payloads.
|
||||
@@ -293,26 +333,22 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
}
|
||||
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
void ws;
|
||||
void code;
|
||||
void reason;
|
||||
void wasClean;
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
void ws;
|
||||
void error;
|
||||
}
|
||||
|
||||
private getOnlineDeviceIdentifiers(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||
if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
|
||||
out.add(attachment.deviceIdentifier);
|
||||
}
|
||||
return Array.from(out);
|
||||
@@ -349,16 +385,45 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastDeviceStatus(userId: string): void {
|
||||
this.broadcastMessage(
|
||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||
{
|
||||
private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (
|
||||
!attachment?.handshakeComplete ||
|
||||
attachment.kind !== 'anonymous-auth-request' ||
|
||||
attachment.authRequestId !== authRequestId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
UserId: userId,
|
||||
Date: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
null
|
||||
);
|
||||
Id: authRequestId,
|
||||
};
|
||||
try {
|
||||
if (attachment.protocol === 'json') {
|
||||
ws.send(buildSignalRJsonInvocation(
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||
payload,
|
||||
contextId,
|
||||
'AuthRequestResponseRecieved'
|
||||
));
|
||||
} else {
|
||||
ws.send(buildSignalRMessagePackInvocation(
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||
payload,
|
||||
contextId,
|
||||
'AuthRequestResponseRecieved'
|
||||
));
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
ws.close(1011, 'Notification send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +436,243 @@ export function notifyUserVaultSync(
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||
}
|
||||
|
||||
export function notifyUserCiphersSync(
|
||||
env: Env,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
contextId?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
|
||||
}
|
||||
|
||||
export function notifyUserCipherCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserCipherUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserCipherDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
cipherId: string;
|
||||
revisionDate: string;
|
||||
organizationId?: string | null;
|
||||
collectionIds?: string[] | null;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.cipherId,
|
||||
OrganizationId: payload.organizationId ?? null,
|
||||
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserFolderDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
folderId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.folderId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendCreate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendUpdate(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserSendDelete(
|
||||
env: Env,
|
||||
payload: {
|
||||
userId: string;
|
||||
sendId: string;
|
||||
revisionDate: string;
|
||||
contextId?: string | null;
|
||||
}
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
payload.userId,
|
||||
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
|
||||
payload.revisionDate,
|
||||
payload.contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: payload.userId,
|
||||
Id: payload.sendId,
|
||||
RevisionDate: payload.revisionDate,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
export function notifyUserLogout(
|
||||
env: Env,
|
||||
userId: string,
|
||||
@@ -392,13 +694,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyAuthRequestResponse(
|
||||
env: Env,
|
||||
userId: string,
|
||||
authRequestId: string,
|
||||
contextId?: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
await stub.fetch('https://notifications/internal/auth-request-response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
authRequestId,
|
||||
contextId: contextId || null,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast auth request response notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyUserAuthRequest(
|
||||
env: Env,
|
||||
userId: string,
|
||||
authRequestId: string,
|
||||
contextId?: string | null
|
||||
): void {
|
||||
waitUntil(notifyUserUpdate(
|
||||
env,
|
||||
userId,
|
||||
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
|
||||
new Date().toISOString(),
|
||||
contextId ?? null,
|
||||
null,
|
||||
{
|
||||
UserId: userId,
|
||||
Id: authRequestId,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
async function notifyUserUpdate(
|
||||
env: Env,
|
||||
userId: string,
|
||||
updateType: number,
|
||||
revisionDate: string,
|
||||
contextId: string | null,
|
||||
targetDeviceIdentifier: string | null
|
||||
targetDeviceIdentifier: string | null,
|
||||
payloadOverride?: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||
@@ -414,12 +762,22 @@ async function notifyUserUpdate(
|
||||
contextId: contextId || null,
|
||||
updateType,
|
||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||
payload: {
|
||||
payload: payloadOverride || {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await notifyMobilePush(env, {
|
||||
userId,
|
||||
updateType,
|
||||
revisionDate,
|
||||
contextId,
|
||||
payload: payloadOverride || {
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast realtime notification:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
accountPasskeyTokenTtlMs,
|
||||
buildWebAuthnPrfOption,
|
||||
createAccountPasskeyToken,
|
||||
getAccountPasskeyRpConfig,
|
||||
isSerializedEncString,
|
||||
normalizeAccountPasskeyName,
|
||||
normalizeAuthenticationResponse,
|
||||
normalizeRegistrationResponse,
|
||||
normalizeTransports,
|
||||
sha256Base64Url,
|
||||
toSimpleWebAuthnCredential,
|
||||
userHandleToUserId,
|
||||
userIdToWebAuthnUserId,
|
||||
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> : {};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
return parseBodyObject(await request.json());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyUserSecret(
|
||||
env: Env,
|
||||
user: User,
|
||||
body: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
|
||||
if (!secret) return false;
|
||||
const storedHash = String(user.masterPasswordHash || '').trim();
|
||||
if (!storedHash) return false;
|
||||
const auth = new AuthService(env);
|
||||
return auth.verifyPassword(secret, storedHash, user.email);
|
||||
}
|
||||
|
||||
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
|
||||
const err = error instanceof Error ? error : null;
|
||||
console.error('Account passkey handler failed', {
|
||||
stage,
|
||||
name: err?.name || typeof error,
|
||||
message: err?.message || String(error),
|
||||
stack: err?.stack,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
function passkeySetupStageMessage(stage: string): string {
|
||||
if (stage === 'verify_master_password') return 'verifying master password';
|
||||
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
|
||||
if (stage === 'generate_options') return 'generating passkey options';
|
||||
if (stage === 'save_challenge') return 'saving passkey challenge';
|
||||
if (stage === 'create_token') return 'creating passkey challenge token';
|
||||
return 'preparing passkey setup';
|
||||
}
|
||||
|
||||
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;
|
||||
encryptedPrivateKey: string | null;
|
||||
} {
|
||||
if (!hasCompletePrfKeySet(body)) {
|
||||
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
|
||||
}
|
||||
const encryptedUserKey = String(body.encryptedUserKey).trim();
|
||||
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
|
||||
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
|
||||
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
|
||||
throw new Error('Invalid encrypted key set');
|
||||
}
|
||||
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
|
||||
}
|
||||
|
||||
async function saveChallenge(
|
||||
storage: StorageService,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
challenge: string,
|
||||
userId: string | null
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
await storage.saveAccountPasskeyChallenge({
|
||||
challengeHash: await sha256Base64Url(challenge),
|
||||
scope,
|
||||
userId,
|
||||
expiresAt: now + accountPasskeyTokenTtlMs(scope),
|
||||
usedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: [],
|
||||
userVerification: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'Authentication', options.challenge, null);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'Authentication',
|
||||
challenge: options.challenge,
|
||||
userId: null,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function assertAccountPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
input: {
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
scope: 'Authentication' | 'UpdateKeySet';
|
||||
expectedUserId?: string | null;
|
||||
}
|
||||
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
|
||||
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
|
||||
if (!payload) {
|
||||
throw new Error('Passkey challenge token is invalid or expired');
|
||||
}
|
||||
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
|
||||
throw new Error('Passkey challenge token does not match this user');
|
||||
}
|
||||
|
||||
const response = normalizeAuthenticationResponse(input.deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
challengeHash,
|
||||
input.scope,
|
||||
payload.userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential) {
|
||||
throw new Error('Passkey is not registered for this server');
|
||||
}
|
||||
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;
|
||||
if (!resolvedUserId || resolvedUserId !== credential.userId) {
|
||||
throw new Error('Passkey user handle does not match this credential');
|
||||
}
|
||||
|
||||
const user = await storage.getUserById(resolvedUserId);
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new Error('Passkey user is not available');
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: true,
|
||||
advancedFIDOConfig: { userVerification: 'required' },
|
||||
});
|
||||
if (!verification.verified || !verification.authenticationInfo.userVerified) {
|
||||
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 { user, credential };
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
return jsonResponse({
|
||||
data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
Data: credentials.map(accountPasskeyCredentialToResponse),
|
||||
object: 'list',
|
||||
Object: 'list',
|
||||
continuationToken: null,
|
||||
ContinuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let stage = 'verify_master_password';
|
||||
try {
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
stage = 'load_existing_credentials';
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
stage = 'generate_options';
|
||||
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: 'required',
|
||||
requireResidentKey: true,
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
(options as any).extensions = {
|
||||
...((options as any).extensions || {}),
|
||||
prf: {},
|
||||
};
|
||||
stage = 'save_challenge';
|
||||
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
|
||||
stage = 'create_token';
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'CreateCredential',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
|
||||
} catch (error) {
|
||||
logAccountPasskeyHandlerError(stage, error, { userId });
|
||||
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyUpdateAssertionOptions(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('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||
const requestedId = String(body.credentialId || body.id || '').trim();
|
||||
if (requestedId) {
|
||||
credentials = credentials.filter((credential) => credential.id === requestedId);
|
||||
if (!credentials.length) return errorResponse('Account passkey not found', 404);
|
||||
}
|
||||
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
|
||||
|
||||
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: 'required',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
|
||||
const token = await createAccountPasskeyToken(env, {
|
||||
scope: 'UpdateKeySet',
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpId,
|
||||
});
|
||||
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||
}
|
||||
|
||||
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
|
||||
if (!payload || payload.userId !== userId) {
|
||||
return errorResponse('Passkey challenge token is invalid or expired', 400);
|
||||
}
|
||||
|
||||
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
|
||||
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
|
||||
return errorResponse('Maximum passkey count reached', 400);
|
||||
}
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
|
||||
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: payload.challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: payload.rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} 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 supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'login',
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
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: prfKeySet.encryptedUserKey,
|
||||
encryptedPublicKey: prfKeySet.encryptedPublicKey,
|
||||
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
|
||||
supportsPrf,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveAccountPasskeyCredential(credential);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.create',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: {
|
||||
prfStatus: accountPasskeyPrfStatus(credential),
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(accountPasskeyCredentialToResponse(credential));
|
||||
}
|
||||
|
||||
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||
try {
|
||||
prfKeySet = readPrfKeySet(body);
|
||||
} catch {
|
||||
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||
}
|
||||
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
|
||||
return errorResponse('Encrypted passkey key set is required', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||
try {
|
||||
assertion = await assertAccountPasskeyCredential(request, env, storage, {
|
||||
token: String(body.token || ''),
|
||||
deviceResponse: body.deviceResponse,
|
||||
scope: 'UpdateKeySet',
|
||||
expectedUserId: userId,
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAccountPasskeyEncryption(
|
||||
userId,
|
||||
assertion.credential.credentialId,
|
||||
prfKeySet.encryptedUserKey,
|
||||
prfKeySet.encryptedPublicKey,
|
||||
prfKeySet.encryptedPrivateKey
|
||||
);
|
||||
if (!updated) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.encryption.enable',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: assertion.credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: 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('Master password verification failed', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
|
||||
if (!deleted) return errorResponse('Passkey not found', 404);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.passkey.delete',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credentialId,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
|
||||
return buildWebAuthnPrfOption(credential);
|
||||
}
|
||||
+801
-75
File diff suppressed because it is too large
Load Diff
+147
-22
@@ -1,8 +1,9 @@
|
||||
import { Env, User, Invite } from '../types';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
@@ -24,16 +25,20 @@ async function writeAuditLog(
|
||||
action: string,
|
||||
targetType: string | null,
|
||||
targetId: string | null,
|
||||
metadata: Record<string, unknown> | null
|
||||
metadata: Record<string, unknown> | null,
|
||||
request?: Request
|
||||
): Promise<void> {
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
category: action.startsWith('admin.user.') ? 'security' : 'system',
|
||||
level: action.startsWith('admin.user.') ? 'security' : 'info',
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
...(request ? auditRequestMetadata(request) : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,23 +69,130 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs
|
||||
export async function handleAdminListAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
||||
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
|
||||
const category = String(url.searchParams.get('category') || '').trim() || null;
|
||||
const level = String(url.searchParams.get('level') || '').trim() || null;
|
||||
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
|
||||
const from = String(url.searchParams.get('from') || '').trim() || null;
|
||||
const to = String(url.searchParams.get('to') || '').trim() || null;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
|
||||
return jsonResponse({
|
||||
data: result.logs.map(log => ({
|
||||
id: log.id,
|
||||
actorUserId: log.actorUserId,
|
||||
actorEmail: log.actorEmail,
|
||||
action: log.action,
|
||||
category: log.category,
|
||||
level: log.level,
|
||||
targetType: log.targetType,
|
||||
targetId: log.targetId,
|
||||
targetUserEmail: log.targetUserEmail,
|
||||
metadata: log.metadata,
|
||||
createdAt: log.createdAt,
|
||||
object: 'auditLog',
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: result.hasMore,
|
||||
object: 'list',
|
||||
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs/settings
|
||||
export async function handleAdminGetAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...await getAuditLogSettings(storage),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/admin/logs/settings
|
||||
export async function handleAdminUpdateAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...settings,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/admin/logs
|
||||
export async function handleAdminClearAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// POST /api/admin/invites
|
||||
export async function handleAdminCreateInvite(
|
||||
request: Request,
|
||||
@@ -115,9 +227,9 @@ export async function handleAdminCreateInvite(
|
||||
};
|
||||
|
||||
await storage.createInvite(invite);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
|
||||
expiresInHours,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||
}
|
||||
@@ -144,7 +256,7 @@ export async function handleAdminListInvites(
|
||||
}
|
||||
|
||||
// DELETE /api/admin/invites/:code
|
||||
export async function handleAdminRevokeInvite(
|
||||
export async function handleAdminDeleteInvite(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
@@ -155,12 +267,14 @@ export async function handleAdminRevokeInvite(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const revoked = await storage.revokeInvite(code);
|
||||
if (!revoked) {
|
||||
return errorResponse('Invite not found or already inactive', 404);
|
||||
const deleted = await storage.deleteInvite(code);
|
||||
if (!deleted) {
|
||||
return errorResponse('Invite not found', 404);
|
||||
}
|
||||
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
|
||||
code,
|
||||
}, request);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -170,16 +284,25 @@ export async function handleAdminDeleteAllInvites(
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('scope') === 'invalid') {
|
||||
const deleted = await storage.deleteInvalidInvites();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_invalid', 'invite', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
|
||||
return jsonResponse({ deleted }, 200);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAllInvites();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||
deleted,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse({ deleted }, 200);
|
||||
}
|
||||
@@ -222,9 +345,10 @@ export async function handleAdminSetUserStatus(
|
||||
if (nextStatus === 'banned') {
|
||||
await storage.deleteRefreshTokensByUserId(target.id);
|
||||
}
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||
status: nextStatus,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse({
|
||||
id: target.id,
|
||||
@@ -280,9 +404,10 @@ export async function handleAdminDeleteUser(
|
||||
|
||||
await storage.deleteRefreshTokensByUserId(target.id);
|
||||
await storage.deleteUserById(target.id);
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||
email: target.email,
|
||||
});
|
||||
targetEmail: target.email,
|
||||
}, request);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
+111
-38
@@ -1,16 +1,17 @@
|
||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import {
|
||||
createAttachmentUploadToken,
|
||||
createFileDownloadToken,
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
getBlobStorageMaxBytes,
|
||||
putBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
@@ -30,6 +32,59 @@ function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function notifyCipherUpdateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserCipherUpdate(env, {
|
||||
userId: cipher.userId,
|
||||
cipherId: cipher.id,
|
||||
revisionDate,
|
||||
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||
: null,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||
const fallback = 'attachment';
|
||||
const value = String(fileName || fallback)
|
||||
.replace(/[\r\n"]/g, '_')
|
||||
.trim() || fallback;
|
||||
return `attachment; filename="${value}"`;
|
||||
}
|
||||
|
||||
async function writeAttachmentAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'attachment',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
@@ -53,6 +108,7 @@ async function runWithConcurrency<T>(
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
attachment: Attachment,
|
||||
cipherId: string
|
||||
): Promise<Response> {
|
||||
@@ -68,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,
|
||||
@@ -94,6 +154,7 @@ async function processAttachmentUpload(
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
@@ -110,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);
|
||||
}
|
||||
@@ -148,16 +209,17 @@ 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);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -186,18 +248,18 @@ 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);
|
||||
}
|
||||
|
||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||
}
|
||||
|
||||
export async function handlePublicUploadAttachment(
|
||||
@@ -225,17 +287,17 @@ 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);
|
||||
}
|
||||
|
||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||
}
|
||||
|
||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
@@ -250,16 +312,17 @@ 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);
|
||||
}
|
||||
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||
|
||||
// Generate short-lived download token
|
||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||
@@ -270,12 +333,12 @@ export async function handleGetAttachment(
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
id: responseAttachment.id,
|
||||
url: downloadUrl,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
fileName: responseAttachment.fileName,
|
||||
key: responseAttachment.key,
|
||||
size: String(Number(responseAttachment.size) || 0),
|
||||
sizeName: responseAttachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,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);
|
||||
}
|
||||
@@ -325,6 +388,7 @@ export async function handleUpdateAttachmentMetadata(
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
@@ -345,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');
|
||||
@@ -358,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);
|
||||
}
|
||||
@@ -377,22 +439,23 @@ 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': object.contentType || 'application/octet-stream',
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -409,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);
|
||||
}
|
||||
@@ -424,20 +487,30 @@ 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);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||
id: attachmentId,
|
||||
cipherId,
|
||||
size: attachment.size,
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
Cipher: cipherResponse,
|
||||
cipher: cipherResponse,
|
||||
Object: 'deleteAttachment',
|
||||
object: 'deleteAttachment',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
|
||||
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;
|
||||
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
|
||||
|
||||
function normalizeText(value: unknown, maxLength: number): string {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isSerializedEncString(value: unknown): value is string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
const parts = text.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const type = Number(parts[0]);
|
||||
const bodyParts = parts[1].split('|');
|
||||
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getClientIp(request: Request): string | null {
|
||||
return (
|
||||
request.headers.get('CF-Connecting-IP') ||
|
||||
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getCountryName(request: Request): string | null {
|
||||
return request.headers.get('CF-IPCountry') || null;
|
||||
}
|
||||
|
||||
function deviceTypeName(type: number): string {
|
||||
const names: Record<number, string> = {
|
||||
0: 'Android',
|
||||
1: 'iOS',
|
||||
2: 'Chrome Extension',
|
||||
3: 'Firefox Extension',
|
||||
4: 'Opera Extension',
|
||||
5: 'Edge Extension',
|
||||
6: 'Windows Desktop',
|
||||
7: 'macOS Desktop',
|
||||
8: 'Linux Desktop',
|
||||
9: 'Chrome',
|
||||
10: 'Firefox',
|
||||
11: 'Opera',
|
||||
12: 'Edge',
|
||||
13: 'Internet Explorer',
|
||||
14: 'Unknown Browser',
|
||||
15: 'Android',
|
||||
16: 'Windows UWP',
|
||||
17: 'Safari',
|
||||
18: 'Vivaldi',
|
||||
19: 'Vivaldi Extension',
|
||||
20: 'Safari Extension',
|
||||
21: 'SDK',
|
||||
22: 'Server',
|
||||
23: 'Windows CLI',
|
||||
24: 'macOS CLI',
|
||||
25: 'Linux CLI',
|
||||
26: 'DuckDuckGo',
|
||||
};
|
||||
return names[type] || `Device ${type}`;
|
||||
}
|
||||
|
||||
function buildOrigin(request: Request): string {
|
||||
return new URL(request.url).host;
|
||||
}
|
||||
|
||||
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
|
||||
return {
|
||||
id: authRequest.id,
|
||||
Id: authRequest.id,
|
||||
publicKey: authRequest.publicKey,
|
||||
PublicKey: authRequest.publicKey,
|
||||
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||
requestDeviceTypeValue: authRequest.requestDeviceType,
|
||||
RequestDeviceTypeValue: authRequest.requestDeviceType,
|
||||
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||
requestIpAddress: authRequest.requestIpAddress,
|
||||
RequestIpAddress: authRequest.requestIpAddress,
|
||||
requestCountryName: authRequest.requestCountryName,
|
||||
RequestCountryName: authRequest.requestCountryName,
|
||||
key: authRequest.key,
|
||||
Key: authRequest.key,
|
||||
masterPasswordHash: null,
|
||||
MasterPasswordHash: null,
|
||||
creationDate: authRequest.creationDate,
|
||||
CreationDate: authRequest.creationDate,
|
||||
responseDate: authRequest.responseDate,
|
||||
ResponseDate: authRequest.responseDate,
|
||||
requestApproved: authRequest.approved ?? false,
|
||||
RequestApproved: authRequest.approved ?? false,
|
||||
requestDeviceId: requestDeviceId ?? null,
|
||||
RequestDeviceId: requestDeviceId ?? null,
|
||||
origin: buildOrigin(request),
|
||||
Origin: buildOrigin(request),
|
||||
object: 'auth-request',
|
||||
Object: 'auth-request',
|
||||
};
|
||||
}
|
||||
|
||||
function listResponse<T>(data: T[]) {
|
||||
return {
|
||||
data,
|
||||
Data: data,
|
||||
object: 'list',
|
||||
Object: 'list',
|
||||
continuationToken: null,
|
||||
ContinuationToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
|
||||
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
|
||||
}
|
||||
|
||||
export async function handleCreateAuthRequest(request: Request, env: Env): 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();
|
||||
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
|
||||
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 (!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);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user || user.status !== 'active') {
|
||||
return errorResponse('User or known device not found.', 400);
|
||||
}
|
||||
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const now = new Date().toISOString();
|
||||
const authRequest: AuthRequestRecord = {
|
||||
id: generateUUID(),
|
||||
userId: user.id,
|
||||
organizationId: null,
|
||||
type,
|
||||
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 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.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const accessCode = normalizeText(url.searchParams.get('code'), 25);
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
|
||||
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequests = await storage.listAuthRequestsByUserId(userId);
|
||||
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
|
||||
}
|
||||
|
||||
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.pruneExpiredAuthRequests();
|
||||
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
|
||||
const rows = await Promise.all(authRequests.map(async (authRequest) => {
|
||||
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
|
||||
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
|
||||
}));
|
||||
return jsonResponse(listResponse(rows));
|
||||
}
|
||||
|
||||
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
|
||||
return errorResponse('Auth request has already been answered.', 409);
|
||||
}
|
||||
|
||||
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
|
||||
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
|
||||
if (latestForDevice?.id !== authRequest.id) {
|
||||
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
|
||||
}
|
||||
|
||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||
const responseDeviceIdentifier =
|
||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||
readActingDeviceIdentifier(request) ||
|
||||
'web';
|
||||
|
||||
if (approved && !key) {
|
||||
return errorResponse('Encrypted key is required to approve the request.', 400);
|
||||
}
|
||||
if (approved && !isSerializedEncString(key)) {
|
||||
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||
approved,
|
||||
responseDeviceIdentifier,
|
||||
key,
|
||||
masterPasswordHash: null,
|
||||
});
|
||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||
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) {
|
||||
await notifyAuthRequestResponse(env, userId, id);
|
||||
}
|
||||
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
|
||||
}
|
||||
+625
-291
File diff suppressed because it is too large
Load Diff
+654
-50
File diff suppressed because it is too large
Load Diff
+215
-10
@@ -1,11 +1,16 @@
|
||||
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||
import { AuthService } from '../services/auth';
|
||||
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);
|
||||
|
||||
function normalizeIdentifier(value: string | null | undefined): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
@@ -43,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,
|
||||
@@ -118,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
|
||||
@@ -219,6 +305,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
pushUuid: null,
|
||||
pushToken: null,
|
||||
devicePendingAuthRequest: null,
|
||||
deviceNote: null,
|
||||
lastSeenAt: null,
|
||||
@@ -265,9 +353,50 @@ export async function handleRevokeTrustedDevice(
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
// POST /api/devices/authorized/:deviceIdentifier/permanent
|
||||
// Upgrades an existing active 2FA remember-token record to permanent trust.
|
||||
export async function handleTrustDevicePermanently(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const normalized = String(deviceIdentifier || '').trim();
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
|
||||
if (!updated) return errorResponse('Device is not currently trusted', 409);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.permanent',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { updated, ...auditRequestMetadata(request) },
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
updated,
|
||||
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/devices/:deviceIdentifier
|
||||
export async function handleDeleteDevice(
|
||||
request: Request,
|
||||
@@ -280,12 +409,24 @@ export async function handleDeleteDevice(
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
@@ -309,6 +450,15 @@ export async function handleUpdateDeviceName(
|
||||
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.name.update',
|
||||
category: 'device',
|
||||
level: 'info',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { name, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
@@ -327,7 +477,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
notifyUserLogout(env, userId, null);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete_all',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||
}
|
||||
|
||||
@@ -419,6 +579,15 @@ export async function handleUntrustDevices(
|
||||
if (!deviceIdentifier) continue;
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke_batch',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
@@ -454,28 +623,58 @@ export async function handleDeactivateDevice(
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||
const deleted = await storage.deleteDevice(userId, normalized);
|
||||
if (deleted) {
|
||||
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.deactivate',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||
// Bitwarden mobile reports push token updates to this endpoint.
|
||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
|
||||
export async function handleUpdateDeviceToken(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
void env;
|
||||
void userId;
|
||||
void deviceIdentifier;
|
||||
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
|
||||
if (!pushToken) return errorResponse('Invalid push token', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
|
||||
const pushUuid = device.pushUuid || generateUUID();
|
||||
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
|
||||
if (updated) {
|
||||
await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: normalized,
|
||||
type: device.type,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -501,9 +700,15 @@ export async function handleClearDeviceToken(
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
void env;
|
||||
void userId;
|
||||
void deviceIdentifier;
|
||||
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const cleared = await storage.clearDevicePushToken(userId, normalized);
|
||||
if (cleared?.pushUuid) {
|
||||
await unregisterMobilePushDevice(env, cleared.pushUuid);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import {
|
||||
buildDomainsResponse,
|
||||
customRulesToActiveEquivalentDomains,
|
||||
normalizeCustomEquivalentDomains,
|
||||
normalizeEquivalentDomains,
|
||||
normalizeExcludedGlobalTypes,
|
||||
} from '../services/domain-rules';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
|
||||
// CONTRACT:
|
||||
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
|
||||
// It stores custom rules, then derives equivalentDomains from the non-excluded
|
||||
// custom rules. Keep this behavior aligned with backup import/export and
|
||||
// src/services/storage-domain-rules-repo.ts.
|
||||
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const parsed = await request.json();
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const settings = await storage.getUserDomainSettings(userId);
|
||||
return jsonResponse(buildDomainsResponse(
|
||||
settings.equivalentDomains,
|
||||
settings.customEquivalentDomains,
|
||||
settings.excludedGlobalEquivalentDomains
|
||||
));
|
||||
}
|
||||
|
||||
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const payload = await readPayload(request);
|
||||
const current = await storage.getUserDomainSettings(userId);
|
||||
const equivalentDomainsRaw = firstPresent(payload, [
|
||||
'equivalentDomains',
|
||||
'EquivalentDomains',
|
||||
]);
|
||||
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||
'customEquivalentDomains',
|
||||
'CustomEquivalentDomains',
|
||||
]);
|
||||
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||
'excludedGlobalEquivalentDomains',
|
||||
'ExcludedGlobalEquivalentDomains',
|
||||
// Some older compatible clients send the excluded type list under this key.
|
||||
'globalEquivalentDomains',
|
||||
'GlobalEquivalentDomains',
|
||||
]);
|
||||
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||
? (equivalentDomainsRaw === undefined
|
||||
? current.customEquivalentDomains
|
||||
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||
? current.excludedGlobalEquivalentDomains
|
||||
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||
|
||||
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||
|
||||
const settings = await storage.getUserDomainSettings(userId);
|
||||
if (!settings) {
|
||||
return errorResponse('Domain settings unavailable', 500);
|
||||
}
|
||||
return jsonResponse(buildDomainsResponse(
|
||||
settings.equivalentDomains,
|
||||
settings.customEquivalentDomains,
|
||||
settings.excludedGlobalEquivalentDomains
|
||||
));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+69
-4
@@ -1,10 +1,16 @@
|
||||
import { Env, Folder, FolderResponse } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import {
|
||||
notifyUserFolderCreate,
|
||||
notifyUserFolderDelete,
|
||||
notifyUserFolderUpdate,
|
||||
notifyUserVaultSync,
|
||||
} from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
@@ -15,6 +21,27 @@ function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
async function writeFolderAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'folder',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Convert internal folder to API response format
|
||||
function folderToResponse(folder: Folder): FolderResponse {
|
||||
return {
|
||||
@@ -53,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);
|
||||
@@ -89,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderCreate(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
|
||||
return jsonResponse(folderToResponse(folder), 200);
|
||||
}
|
||||
@@ -96,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);
|
||||
@@ -117,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
await storage.saveFolder(folder);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderUpdate(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
@@ -124,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);
|
||||
@@ -134,6 +173,15 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
||||
await storage.deleteFolder(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserFolderDelete(env, {
|
||||
userId,
|
||||
folderId: id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||
id,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -154,9 +202,26 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
||||
return errorResponse('Folder ids are required', 400);
|
||||
}
|
||||
|
||||
const folders = (
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
return folder;
|
||||
}))
|
||||
).filter((folder): folder is Folder => !!folder);
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
for (const folder of folders) {
|
||||
notifyUserFolderDelete(env, {
|
||||
userId,
|
||||
folderId: folder.id,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||
count: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
+479
-59
@@ -1,28 +1,45 @@
|
||||
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';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { issueSendAccessToken } from './sends';
|
||||
import { registerMobilePushDevice } from '../services/push-relay';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
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';
|
||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||
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.
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
||||
|
||||
function resolveTotpSecret(userSecret: string | null): string | null {
|
||||
@@ -32,6 +49,55 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveDeviceSession(
|
||||
storage: StorageService,
|
||||
userId: string,
|
||||
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
|
||||
): Promise<{ identifier: string; sessionStamp: string } | null> {
|
||||
if (!deviceInfo.deviceIdentifier) return null;
|
||||
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
|
||||
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
|
||||
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
||||
}
|
||||
|
||||
function readDevicePushToken(body: Record<string, string>): string {
|
||||
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
|
||||
}
|
||||
|
||||
async function persistIdentityDevicePushToken(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
userId: string,
|
||||
deviceSession: { identifier: string; sessionStamp: string } | null,
|
||||
deviceType: number,
|
||||
body: Record<string, string>
|
||||
): Promise<void> {
|
||||
if (!deviceSession) return;
|
||||
const pushToken = readDevicePushToken(body);
|
||||
if (!pushToken) return;
|
||||
|
||||
const device = await storage.getDevice(userId, deviceSession.identifier);
|
||||
if (!device) return;
|
||||
|
||||
const pushUuid = device.pushUuid || generateUUID();
|
||||
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
|
||||
const registered = await registerMobilePushDevice(env, {
|
||||
userId,
|
||||
deviceIdentifier: deviceSession.identifier,
|
||||
type: device.type || deviceType,
|
||||
pushUuid,
|
||||
pushToken,
|
||||
});
|
||||
console.info('Mobile push token updated from identity token request', {
|
||||
userId,
|
||||
deviceIdentifier: deviceSession.identifier,
|
||||
deviceType: device.type || deviceType,
|
||||
pushUuid,
|
||||
pushTokenLength: pushToken.length,
|
||||
relayRegistered: registered,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldUseWebSession(request: Request): boolean {
|
||||
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||
}
|
||||
@@ -48,16 +114,31 @@ 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];
|
||||
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = body[name];
|
||||
if (value != null) return value;
|
||||
}
|
||||
return diff === 0;
|
||||
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 {
|
||||
@@ -92,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,
|
||||
@@ -114,19 +219,51 @@ function buildPreloginResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
||||
const providers = includeRecoveryCode
|
||||
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
||||
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||
const providers2: Record<string, null> = {};
|
||||
for (const provider of providers) providers2[provider] = null;
|
||||
function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||
return {
|
||||
minComplexity: 0,
|
||||
minLength: 0,
|
||||
requireUpper: false,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
enforceOnLogin: false,
|
||||
Object: 'masterPasswordPolicy',
|
||||
object: 'masterPasswordPolicy',
|
||||
};
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
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,
|
||||
SsoEmail2faSessionToken: null,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||
};
|
||||
|
||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||
@@ -212,16 +349,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
const passwordHash = body.password;
|
||||
const twoFactorToken = body.twoFactorToken;
|
||||
const twoFactorProvider = body.twoFactorProvider;
|
||||
const twoFactorRemember = body.twoFactorRemember;
|
||||
const loginIdentifier = `${clientIdentifier}:${email}`;
|
||||
const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
|
||||
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
||||
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
||||
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
||||
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);
|
||||
@@ -240,11 +378,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
let validatedAuthRequestId: string | null = null;
|
||||
let authRequestLoginKey: string | null = null;
|
||||
let valid = false;
|
||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||
if (normalizedAuthRequestId) {
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||
valid = !!(
|
||||
authRequest &&
|
||||
authRequest.userId === user.id &&
|
||||
authRequest.type === 0 &&
|
||||
authRequest.approved === true &&
|
||||
authRequest.responseDate &&
|
||||
!authRequest.authenticationDate &&
|
||||
!isAuthRequestExpired(authRequest) &&
|
||||
!!authRequest.key &&
|
||||
constantTimeEquals(authRequest.accessCode, passwordHash)
|
||||
);
|
||||
if (valid) {
|
||||
validatedAuthRequestId = authRequest!.id;
|
||||
authRequestLoginKey = authRequest!.key;
|
||||
}
|
||||
} else {
|
||||
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
}
|
||||
if (!valid) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
loginIdentifier,
|
||||
@@ -252,11 +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 canUseRecoveryCode = !!user.totpRecoveryCode;
|
||||
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());
|
||||
@@ -266,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.', canUseRecoveryCode);
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
|
||||
let passedByRememberToken = false;
|
||||
@@ -281,26 +469,68 @@ 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.', canUseRecoveryCode);
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (!totpOk) {
|
||||
if (!effectiveTotpSecret) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (matchedCounter == null) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
|
||||
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_LEGACY) ||
|
||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
|
||||
) {
|
||||
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
||||
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.
|
||||
@@ -320,10 +550,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
// Persist device only after successful password + (optional) 2FA verification.
|
||||
const deviceSession =
|
||||
deviceInfo.deviceIdentifier
|
||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||
: null;
|
||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
@@ -332,15 +559,34 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
if (validatedAuthRequestId) {
|
||||
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
|
||||
}
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -348,6 +594,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: authRequestLoginKey || user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'webauthn') {
|
||||
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(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
let deviceResponse: unknown = body.deviceResponse;
|
||||
if (typeof deviceResponse === 'string') {
|
||||
try {
|
||||
deviceResponse = JSON.parse(deviceResponse);
|
||||
} catch {
|
||||
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
|
||||
}
|
||||
}
|
||||
if (!token || !deviceResponse) {
|
||||
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||
try {
|
||||
asserted = await assertAccountPasskeyCredential(request, env, storage, {
|
||||
token,
|
||||
deviceResponse,
|
||||
scope: 'Authentication',
|
||||
});
|
||||
} catch (error) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: null,
|
||||
action: 'auth.passkey.login.failed',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: {
|
||||
grantType,
|
||||
reason: error instanceof Error ? error.message : 'assertion_failed',
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const { user, credential } = asserted;
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
deviceSession.identifier,
|
||||
deviceInfo.deviceName,
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.passkey.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
@@ -358,12 +724,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserVerificationToken: userVerificationToken,
|
||||
userVerificationToken,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
@@ -380,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}:${clientId}`;
|
||||
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);
|
||||
@@ -396,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);
|
||||
@@ -404,19 +770,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
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,
|
||||
action: 'auth.login.failed.bad_api_key',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Persist device only after successful client credential verification.
|
||||
const deviceSession =
|
||||
deviceInfo.deviceIdentifier
|
||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||
: null;
|
||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
@@ -425,6 +814,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
@@ -434,6 +824,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -450,9 +855,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
@@ -502,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;
|
||||
@@ -538,8 +941,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
if (!result) {
|
||||
const result = await auth.refreshAccessTokenDetailed(refreshToken);
|
||||
if (!result.ok) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: result.userId ?? null,
|
||||
action: `auth.refresh.failed.${result.reason}`,
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
|
||||
targetId: result.deviceIdentifier ?? null,
|
||||
metadata: {
|
||||
grantType,
|
||||
reason: result.reason,
|
||||
webSession: shouldUseWebSession(request),
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, invalidResponse, null)
|
||||
@@ -576,9 +993,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
MasterPasswordPolicy: masterPasswordPolicyResponse(),
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
@@ -629,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') || '';
|
||||
|
||||
+34
-10
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
|
||||
|
||||
// Bitwarden client import request format
|
||||
interface CiphersImportRequest {
|
||||
@@ -17,9 +17,12 @@ 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; match?: number | null }> | null;
|
||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | 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']);
|
||||
@@ -195,7 +212,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
uris: login.uris?.map((u: any) => ({
|
||||
...u,
|
||||
uri: u.uri ?? null,
|
||||
uriChecksum: null,
|
||||
uriChecksum: u.uriChecksum ?? null,
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: login.totp ?? null,
|
||||
@@ -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,
|
||||
@@ -252,6 +272,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) {
|
||||
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
|
||||
}
|
||||
|
||||
cipherRows.push(cipher);
|
||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||
|
||||
@@ -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';
|
||||
@@ -56,3 +58,24 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
|
||||
}
|
||||
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||
}
|
||||
|
||||
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
|
||||
if (!authRequestId) return errorResponse('Token is required', 400);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||
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);
|
||||
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
|
||||
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
getBlobStorageMaxBytes,
|
||||
getSendFileObjectKey,
|
||||
getBlobObject,
|
||||
putBlobObject,
|
||||
deleteBlobObject,
|
||||
} from '../services/blob-store';
|
||||
@@ -16,6 +17,9 @@ import {
|
||||
formatSize,
|
||||
getAliasedProp,
|
||||
normalizeEmails,
|
||||
notifySendCreateForRequest,
|
||||
notifySendDeleteForRequest,
|
||||
notifySendUpdateForRequest,
|
||||
notifyVaultSyncForRequest,
|
||||
parseDate,
|
||||
parseFileLength,
|
||||
@@ -29,6 +33,30 @@ import {
|
||||
setSendPassword,
|
||||
validateDeletionDate,
|
||||
} 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,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'send',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processSendFileUpload(
|
||||
request: Request,
|
||||
@@ -57,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: {
|
||||
@@ -77,6 +110,7 @@ async function processSendFileUpload(
|
||||
const storage = new StorageService(env.DB);
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
@@ -108,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);
|
||||
@@ -184,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 = {
|
||||
@@ -227,6 +267,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
@@ -307,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 = {
|
||||
@@ -350,6 +397,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||
const jwtSecret = getSafeJwtSecret(env);
|
||||
if (!jwtSecret) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
@@ -373,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);
|
||||
}
|
||||
@@ -408,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);
|
||||
}
|
||||
@@ -444,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);
|
||||
}
|
||||
@@ -457,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);
|
||||
}
|
||||
@@ -564,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']);
|
||||
@@ -576,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;
|
||||
}
|
||||
}
|
||||
@@ -597,14 +649,14 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleDeleteSend(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);
|
||||
}
|
||||
@@ -620,6 +672,11 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
||||
await storage.deleteSend(sendId, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||
id: sendId,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -651,15 +708,21 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
for (const send of sends) {
|
||||
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
|
||||
}
|
||||
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||
count: sends.length,
|
||||
requestedCount: body.ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleRemoveSendPassword(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);
|
||||
}
|
||||
@@ -669,14 +732,18 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleRemoveSendAuth(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);
|
||||
}
|
||||
@@ -687,6 +754,11 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import {
|
||||
createSendAccessToken,
|
||||
createSendFileDownloadToken,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getSafeJwtSecret,
|
||||
hasEmailAuth,
|
||||
isSendAvailable,
|
||||
notifySendUpdateForRequest,
|
||||
notifyVaultSyncForRequest,
|
||||
parseStoredSendData,
|
||||
resolveSendFromIdOrAccessId,
|
||||
@@ -33,6 +34,14 @@ import {
|
||||
verifySendPasswordHashB64,
|
||||
} from './sends-shared';
|
||||
|
||||
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||
const fallback = 'send-file';
|
||||
const value = String(fileName || fallback)
|
||||
.replace(/[\r\n"]/g, '_')
|
||||
.trim() || fallback;
|
||||
return `attachment; filename="${value}"`;
|
||||
}
|
||||
|
||||
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const sendId = fromAccessId(accessId);
|
||||
@@ -59,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) {
|
||||
@@ -90,6 +99,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
@@ -102,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);
|
||||
@@ -133,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) {
|
||||
@@ -163,6 +172,7 @@ export async function handleAccessSendFile(
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||
|
||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||
const url = new URL(request.url);
|
||||
@@ -203,6 +213,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||
}
|
||||
|
||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||
@@ -242,6 +253,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
|
||||
send.accessCount += 1;
|
||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||
|
||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||
const url = new URL(request.url);
|
||||
@@ -278,9 +290,14 @@ 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);
|
||||
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);
|
||||
@@ -288,11 +305,19 @@ export async function handleDownloadSendFile(
|
||||
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': object.contentType || 'application/octet-stream',
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -303,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) {
|
||||
@@ -343,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,5 +1,10 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||
import {
|
||||
notifyUserSendCreate,
|
||||
notifyUserSendDelete,
|
||||
notifyUserSendUpdate,
|
||||
notifyUserVaultSync,
|
||||
} from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
export function notifySendCreateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendCreate(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifySendUpdateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendUpdate(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function notifySendDeleteForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
sendId: string,
|
||||
userId: string,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserSendDelete(env, {
|
||||
userId,
|
||||
sendId,
|
||||
revisionDate,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||
for (const key of aliases) {
|
||||
@@ -321,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
@@ -384,8 +434,8 @@ export type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||
}
|
||||
|
||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||
@@ -414,7 +464,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
|
||||
|
||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||
if (hasEmailAuth(send)) {
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
return {
|
||||
ok: false,
|
||||
response: errorResponse('Send email verification is not supported by this server.', 501),
|
||||
reason: 'email_auth_unsupported',
|
||||
};
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
+52
-39
@@ -1,19 +1,34 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionCompat,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { buildDomainsResponse } from '../services/domain-rules';
|
||||
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||
import { buildProfileResponse } from '../utils/profile-response';
|
||||
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||
// CONTRACT:
|
||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||
function buildSyncCacheRequest(
|
||||
request: Request,
|
||||
userId: string,
|
||||
revisionDate: string,
|
||||
accountPasskeyCacheTag: string,
|
||||
excludeDomains: boolean,
|
||||
excludeSends: boolean,
|
||||
preserveRepairableUris: boolean
|
||||
): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
@@ -37,56 +52,49 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
||||
const [revisionDate, accountPasskeys] = await Promise.all([
|
||||
storage.getRevisionDate(userId),
|
||||
storage.getAccountPasskeyCredentialsByUserId(userId),
|
||||
]);
|
||||
const accountPasskeyCacheTag = accountPasskeys
|
||||
.map((credential) => [
|
||||
credential.id,
|
||||
credential.updatedAt,
|
||||
credential.supportsPrf ? '1' : '0',
|
||||
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
|
||||
].join(':'))
|
||||
.join(',');
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
const webAuthnPrfOptions = accountPasskeys
|
||||
.map(buildWebAuthnPrfOption)
|
||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||
const validFolderIds = new Set(folders.map((folder) => folder.id));
|
||||
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: true,
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
providerOrganizations: [],
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices,
|
||||
object: 'profile',
|
||||
};
|
||||
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
@@ -111,17 +119,22 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
ciphers: cipherResponses,
|
||||
domains: excludeDomains
|
||||
? null
|
||||
: {
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
},
|
||||
: buildDomainsResponse(
|
||||
domainSettings?.equivalentDomains || [],
|
||||
domainSettings?.customEquivalentDomains || [],
|
||||
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||
{ omitExcludedGlobals: true }
|
||||
),
|
||||
policies: [],
|
||||
policiesNew: [],
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||
V2UpgradeToken: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
|
||||
+26
-4
@@ -1,5 +1,6 @@
|
||||
import { Env } from './types';
|
||||
import { NotificationsHub } from './durable/notifications-hub';
|
||||
import { BackupTransferRunner } from './durable/backup-transfer-runner';
|
||||
import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
@@ -31,13 +32,33 @@ function isWorkerHandledPath(path: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function addSearchIndexHeaders(request: Request, response: Response): Response {
|
||||
const url = new URL(request.url);
|
||||
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
|
||||
const shouldNoIndex =
|
||||
url.pathname === '/robots.txt' ||
|
||||
contentType.includes('text/html');
|
||||
|
||||
if (!shouldNoIndex) return response;
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||
if (!env.ASSETS) return null;
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||
const url = new URL(request.url);
|
||||
if (isWorkerHandledPath(url.pathname)) return null;
|
||||
|
||||
return env.ASSETS.fetch(request);
|
||||
const response = await env.ASSETS.fetch(request);
|
||||
return addSearchIndexHeaders(request, response);
|
||||
}
|
||||
|
||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
@@ -68,7 +89,7 @@ export default {
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
return applyCors(normalizedRequest, assetResponse, env);
|
||||
}
|
||||
|
||||
await ensureDatabaseInitialized(env);
|
||||
@@ -86,11 +107,11 @@ export default {
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
}
|
||||
|
||||
const resp = await handleRequest(normalizedRequest, env);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
},
|
||||
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
@@ -107,3 +128,4 @@ export default {
|
||||
};
|
||||
|
||||
export { NotificationsHub };
|
||||
export { BackupTransferRunner };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ export async function handleAdminBackupRoute(
|
||||
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'POST') {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+44
-2
@@ -4,11 +4,32 @@ import {
|
||||
handleAdminCreateInvite,
|
||||
handleAdminListInvites,
|
||||
handleAdminDeleteAllInvites,
|
||||
handleAdminRevokeInvite,
|
||||
handleAdminDeleteInvite,
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
handleAdminListAuditLogs,
|
||||
handleAdminGetAuditLogSettings,
|
||||
handleAdminUpdateAuditLogSettings,
|
||||
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,
|
||||
@@ -17,10 +38,31 @@ 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);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs' && method === 'GET') {
|
||||
return handleAdminListAuditLogs(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs' && method === 'DELETE') {
|
||||
return handleAdminClearAuditLogs(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/logs/settings') {
|
||||
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
|
||||
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||
if (adminBackupResponse) return adminBackupResponse;
|
||||
|
||||
@@ -34,7 +76,7 @@ export async function handleAdminRoute(
|
||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||
if (adminInviteMatch && method === 'DELETE') {
|
||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
|
||||
return handleAdminDeleteInvite(request, env, actorUser, inviteCode);
|
||||
}
|
||||
|
||||
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||
|
||||
+175
-13
@@ -1,8 +1,9 @@
|
||||
import type { Env, User } from './types';
|
||||
import { errorResponse, jsonResponse } from './utils/response';
|
||||
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
|
||||
import {
|
||||
handleGetProfile,
|
||||
handleUpdateProfile,
|
||||
handleGetKeys,
|
||||
handleSetKeys,
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
@@ -11,6 +12,16 @@ import {
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetTwoFactorProviders,
|
||||
handleGetTwoFactorAuthenticator,
|
||||
handlePutTwoFactorAuthenticator,
|
||||
handleGetTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKeyConfig,
|
||||
handleBootstrapTwoFactorYubiKeyConfig,
|
||||
handleGetDeviceVerificationSettings,
|
||||
handlePutDeviceVerificationSettings,
|
||||
handleDisableTwoFactorProvider,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
} from './handlers/accounts';
|
||||
@@ -65,6 +76,26 @@ import {
|
||||
} from './handlers/attachments';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
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,
|
||||
handleUpdateAuthRequest,
|
||||
} from './handlers/auth-requests';
|
||||
|
||||
export async function handleAuthenticatedRoute(
|
||||
request: Request,
|
||||
@@ -86,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);
|
||||
@@ -96,8 +161,10 @@ export async function handleAuthenticatedRoute(
|
||||
return handleChangePassword(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||
return handleSetKeys(request, env, userId);
|
||||
if (path === '/api/accounts/keys') {
|
||||
if (method === 'GET') return handleGetKeys(request, env, userId);
|
||||
if (method === 'POST') return handleSetKeys(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/totp') {
|
||||
@@ -110,6 +177,66 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor') {
|
||||
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
|
||||
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);
|
||||
}
|
||||
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
}
|
||||
@@ -130,6 +257,28 @@ export async function handleAuthenticatedRoute(
|
||||
return handleRotateApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/webauthn' || path === '/webauthn') {
|
||||
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
|
||||
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
|
||||
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
|
||||
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
const accountPasskeyDeleteMatch =
|
||||
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
|
||||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
|
||||
if (accountPasskeyDeleteMatch && method === 'POST') {
|
||||
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
@@ -231,8 +380,26 @@ export async function handleAuthenticatedRoute(
|
||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/auth-requests')) {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
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' || path === '/auth-requests/pending') {
|
||||
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
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]);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||
@@ -297,14 +464,9 @@ export async function handleAuthenticatedRoute(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/settings/domains') {
|
||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
||||
return jsonResponse({
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
});
|
||||
}
|
||||
if (path === '/api/settings/domains' || path === '/settings/domains') {
|
||||
if (method === 'GET') return handleGetDomains(env, userId);
|
||||
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+32
-14
@@ -11,14 +11,21 @@ import {
|
||||
handleDeactivateDevice,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleTrustDevicePermanently,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceName,
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
handleRegisterDevice,
|
||||
handleReportLostTrust,
|
||||
} from './handlers/devices';
|
||||
|
||||
function devicesPath(pattern: string): RegExp {
|
||||
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
|
||||
}
|
||||
|
||||
export async function handleAuthenticatedDeviceRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -26,25 +33,36 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/devices') {
|
||||
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/authorized') {
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
|
||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
|
||||
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||
if (deleteDeviceMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||
@@ -54,59 +72,59 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
|
||||
if (updateDeviceNameMatch && method === 'PUT') {
|
||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
|
||||
if (identifierMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
||||
const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
|
||||
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
|
||||
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
||||
const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
|
||||
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||
const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
|
||||
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
||||
const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
|
||||
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
||||
const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
|
||||
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/update-trust' && method === 'POST') {
|
||||
if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
|
||||
return handleUpdateDeviceTrust(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/devices/untrust' && method === 'POST') {
|
||||
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
|
||||
return handleUntrustDevices(request, env, userId);
|
||||
}
|
||||
|
||||
|
||||
+217
-31
@@ -1,5 +1,4 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
@@ -8,29 +7,50 @@ 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 {
|
||||
handleRegister,
|
||||
handleGetPasswordHint,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleCreateAuthRequest,
|
||||
handleGetAuthRequestResponse,
|
||||
} from './handlers/auth-requests';
|
||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||
import {
|
||||
handleAnonymousNotificationsHub,
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||
import { jsonResponse } from './utils/response';
|
||||
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||
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 {
|
||||
@@ -88,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
@@ -100,7 +121,7 @@ function buildConfigResponse(origin: string) {
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: null,
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
@@ -113,11 +134,14 @@ function buildConfigResponse(origin: string) {
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': true,
|
||||
'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,
|
||||
},
|
||||
@@ -142,6 +166,18 @@ function normalizeIconHost(rawHost: string): string | null {
|
||||
}
|
||||
|
||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
|
||||
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||
|
||||
type IconSource = {
|
||||
url: string;
|
||||
rejectImage?: {
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
};
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
@@ -161,65 +197,144 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }):
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function getPositiveContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get('Content-Length');
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
|
||||
if (!response.body) return null;
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
void reader.cancel().catch(() => undefined);
|
||||
}, ICON_UPSTREAM_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (timedOut || totalBytes === 0) return null;
|
||||
|
||||
const output = new ArrayBuffer(totalBytes);
|
||||
const bytes = new Uint8Array(output);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const encodedHost = encodeURIComponent(normalizedHost);
|
||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
||||
const upstreamSources: IconSource[] = [
|
||||
{
|
||||
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
{
|
||||
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||
headers: requestHeaders,
|
||||
rejectImage: {
|
||||
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
|
||||
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
|
||||
},
|
||||
{
|
||||
url: `https://favicon.im/${encodedHost}`,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
{
|
||||
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
for (const source of upstreamSources) {
|
||||
try {
|
||||
const resp = await fetchIconSource(source);
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!contentType.startsWith('image/')) continue;
|
||||
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||
|
||||
return new Response(resp.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
},
|
||||
});
|
||||
const contentLength = getPositiveContentLength(resp.headers);
|
||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||
|
||||
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
|
||||
if (!bytes) continue;
|
||||
if (
|
||||
source.rejectImage &&
|
||||
bytes.byteLength === source.rejectImage.byteLength &&
|
||||
(await sha256Hex(bytes)) === source.rejectImage.sha256
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||
} catch {
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
|
||||
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
||||
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
const storage = new StorageService(env.DB);
|
||||
const userCount = await storage.getUserCount();
|
||||
|
||||
return {
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
registrationInviteRequired: userCount > 0,
|
||||
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,13 +358,34 @@ export async function handlePublicRoute(
|
||||
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return jsonResponse(buildWebBootstrapResponse(env));
|
||||
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);
|
||||
@@ -299,6 +435,19 @@ export async function handlePublicRoute(
|
||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||
}
|
||||
|
||||
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);
|
||||
if (authRequestResponseMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
|
||||
}
|
||||
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
@@ -332,10 +481,41 @@ export async function handlePublicRoute(
|
||||
return handlePrelogin(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleGetAccountPasskeyAssertionOptions(request, env);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -352,7 +532,7 @@ export async function handlePublicRoute(
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = new URL(request.url).origin;
|
||||
return jsonResponse(buildConfigResponse(origin));
|
||||
return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
|
||||
}
|
||||
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
@@ -380,5 +560,11 @@ export async function handlePublicRoute(
|
||||
if (path === '/notifications/hub' && method === 'GET') {
|
||||
return handleNotificationsHub(request, env);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { Env } from '../types';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { StorageService } from './storage';
|
||||
|
||||
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
category: AuditLogCategory;
|
||||
level?: AuditLogLevel;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
|
||||
const MAX_METADATA_BYTES = 2048;
|
||||
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const AUDIT_CLEANUP_PROBABILITY = 0.02;
|
||||
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
|
||||
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
|
||||
retentionDays: 90,
|
||||
maxEntries: null,
|
||||
};
|
||||
let lastAuditCleanupAt = 0;
|
||||
|
||||
export interface AuditLogSettings {
|
||||
retentionDays: number | null;
|
||||
maxEntries: number | null;
|
||||
}
|
||||
|
||||
const ALLOWED_METADATA_KEYS = new Set([
|
||||
'method',
|
||||
'path',
|
||||
'ip',
|
||||
'userAgent',
|
||||
'email',
|
||||
'targetEmail',
|
||||
'grantType',
|
||||
'webSession',
|
||||
'deviceIdentifier',
|
||||
'deviceType',
|
||||
'reason',
|
||||
'status',
|
||||
'verifyDevices',
|
||||
'changed',
|
||||
'removed',
|
||||
'updated',
|
||||
'deleted',
|
||||
'removedTrusted',
|
||||
'removedSessions',
|
||||
'removedDevices',
|
||||
'requested',
|
||||
'count',
|
||||
'requestedCount',
|
||||
'type',
|
||||
'folderId',
|
||||
'cipherId',
|
||||
'size',
|
||||
'users',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
'skippedAttachments',
|
||||
'skippedReason',
|
||||
'replaceExisting',
|
||||
'provider',
|
||||
'prfStatus',
|
||||
'fileName',
|
||||
'fileBytes',
|
||||
'bytes',
|
||||
'compressedBytes',
|
||||
'includesAttachments',
|
||||
'destinationName',
|
||||
'destinationId',
|
||||
'destinationType',
|
||||
'destinationCount',
|
||||
'scheduledDestinationCount',
|
||||
'retentionDays',
|
||||
'maxEntries',
|
||||
'remotePath',
|
||||
'trigger',
|
||||
'prunedFileCount',
|
||||
'pruneError',
|
||||
'uploadVerificationAttempts',
|
||||
'error',
|
||||
'expiresInHours',
|
||||
'checksumMismatchAccepted',
|
||||
]);
|
||||
|
||||
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
|
||||
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
|
||||
const parsed = Math.floor(Number(value));
|
||||
return allowed.includes(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
|
||||
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
|
||||
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
|
||||
|
||||
if (retentionDays) return { retentionDays, maxEntries: null };
|
||||
if (maxEntries) return { retentionDays: null, maxEntries };
|
||||
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
|
||||
return { retentionDays: null, maxEntries: null };
|
||||
}
|
||||
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
|
||||
return { retentionDays: null, maxEntries: null };
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_AUDIT_LOG_SETTINGS,
|
||||
};
|
||||
}
|
||||
|
||||
export function auditRequestMetadata(request: Request): Record<string, unknown> {
|
||||
const url = new URL(request.url);
|
||||
return {
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
|
||||
userAgent: request.headers.get('User-Agent') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (SENSITIVE_KEY_RE.test(key)) continue;
|
||||
if (Array.isArray(value)) {
|
||||
clean[key] = value.length;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object') continue;
|
||||
clean[key] = value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
|
||||
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
|
||||
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||
try {
|
||||
return normalizeAuditLogSettings(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
|
||||
const normalized = normalizeAuditLogSettings(settings);
|
||||
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
|
||||
await applyAuditLogRetention(storage, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
|
||||
const current = settings || await getAuditLogSettings(storage);
|
||||
if (current.retentionDays) {
|
||||
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await storage.pruneAuditLogs(before);
|
||||
}
|
||||
if (current.maxEntries) {
|
||||
await storage.pruneAuditLogsToMax(current.maxEntries);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
|
||||
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
|
||||
lastAuditCleanupAt = now;
|
||||
await applyAuditLogRetention(storage);
|
||||
}
|
||||
|
||||
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||
const metadata = sanitizeMetadata(event.metadata || {});
|
||||
let metadataJson = JSON.stringify(metadata);
|
||||
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
|
||||
metadataJson = JSON.stringify({ truncated: true });
|
||||
}
|
||||
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId: event.actorUserId ?? null,
|
||||
action: event.action,
|
||||
category: event.category,
|
||||
level: event.level || 'info',
|
||||
targetType: event.targetType ?? null,
|
||||
targetId: event.targetId ?? null,
|
||||
metadata: metadataJson,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
await maybePruneAuditLogs(storage);
|
||||
}
|
||||
|
||||
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||
try {
|
||||
await insertAuditEvent(storage, event);
|
||||
} catch (error) {
|
||||
console.error('audit log write failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
|
||||
await writeAuditEvent(new StorageService(env.DB), event);
|
||||
}
|
||||
+66
-22
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
|
||||
// The client already does heavy PBKDF2 (600k iterations).
|
||||
// This second layer only needs to be non-trivial, not expensive.
|
||||
const SERVER_HASH_ITERATIONS = 100_000;
|
||||
const SERVER_HASH_PREFIX = '$s$';
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
@@ -23,6 +24,22 @@ export interface VerifiedAccessContext {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export type RefreshAccessTokenFailureReason =
|
||||
| 'token_not_found_or_expired'
|
||||
| 'user_missing'
|
||||
| 'user_inactive'
|
||||
| 'device_missing'
|
||||
| 'device_session_mismatch';
|
||||
|
||||
export type RefreshAccessTokenResult =
|
||||
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
|
||||
| {
|
||||
ok: false;
|
||||
reason: RefreshAccessTokenFailureReason;
|
||||
userId?: string | null;
|
||||
deviceIdentifier?: string | null;
|
||||
};
|
||||
|
||||
export class AuthService {
|
||||
private storage: StorageService;
|
||||
private static userCache = new Map<string, CachedUserEntry>();
|
||||
@@ -32,6 +49,25 @@ export class AuthService {
|
||||
this.storage = new StorageService(env.DB);
|
||||
}
|
||||
|
||||
static invalidateUserCache(userId: string): void {
|
||||
const normalizedUserId = String(userId || '').trim();
|
||||
if (!normalizedUserId) return;
|
||||
AuthService.userCache.delete(normalizedUserId);
|
||||
const prefix = `${normalizedUserId}:`;
|
||||
for (const key of AuthService.deviceCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
AuthService.deviceCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static invalidateDeviceCache(userId: string, deviceId: string): void {
|
||||
const normalizedUserId = String(userId || '').trim();
|
||||
const normalizedDeviceId = String(deviceId || '').trim();
|
||||
if (!normalizedUserId || !normalizedDeviceId) return;
|
||||
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
|
||||
}
|
||||
|
||||
private readCachedUser(userId: string): User | null | undefined {
|
||||
const cached = AuthService.userCache.get(userId);
|
||||
if (!cached) return undefined;
|
||||
@@ -98,7 +134,7 @@ export class AuthService {
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -116,20 +152,17 @@ export class AuthService {
|
||||
const bytes = new Uint8Array(bits);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return '$s$' + btoa(binary);
|
||||
return SERVER_HASH_PREFIX + btoa(binary);
|
||||
}
|
||||
|
||||
// Verify password: hash the input the same way, then constant-time compare.
|
||||
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
|
||||
// New server-hashed passwords are prefixed with "$s$".
|
||||
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
|
||||
if (email && storedHash.startsWith('$s$')) {
|
||||
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||
return this.constantTimeEquals(inputHash, storedHash);
|
||||
}
|
||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||
return this.constantTimeEquals(serverHash, storedHash);
|
||||
}
|
||||
// Legacy path: direct constant-time comparison of raw client hashes.
|
||||
return this.constantTimeEquals(inputHash, storedHash);
|
||||
}
|
||||
|
||||
private constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
@@ -204,34 +237,45 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
|
||||
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||
if (!record?.userId) return null;
|
||||
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
|
||||
|
||||
const user = await this.storage.getUserById(record.userId);
|
||||
if (!user) return null;
|
||||
if (!user) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return null;
|
||||
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
|
||||
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||
if (record.deviceIdentifier) {
|
||||
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
|
||||
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||
if (!boundDevice) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return null;
|
||||
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||
await this.storage.deleteRefreshToken(refreshToken);
|
||||
return null;
|
||||
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||
}
|
||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||
}
|
||||
|
||||
const accessToken = await this.generateAccessToken(user, device);
|
||||
return { accessToken, user, device };
|
||||
return { ok: true, accessToken, user, device };
|
||||
}
|
||||
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||
const result = await this.refreshAccessTokenDetailed(refreshToken);
|
||||
return result.ok ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
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';
|
||||
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||
import {
|
||||
getAttachmentObjectKey,
|
||||
getBlobStorageKind,
|
||||
} from './blob-store';
|
||||
|
||||
// CONTRACT:
|
||||
// This file defines the exported instance-backup archive shape. Keep it in lock
|
||||
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||
// and validateBackupPayloadContents().
|
||||
// - Keep secrets and transient runtime rows sanitized before writing db.json.
|
||||
// - users.api_key is intentionally not exported.
|
||||
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||
// envelope must not leave the instance.
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
const BACKUP_FORMAT_VERSION = 1;
|
||||
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||
// 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;
|
||||
@@ -48,10 +63,13 @@ export interface BackupPayload {
|
||||
db: {
|
||||
config: SqlRow[];
|
||||
users: SqlRow[];
|
||||
domain_settings: SqlRow[];
|
||||
user_revisions: SqlRow[];
|
||||
folders: SqlRow[];
|
||||
ciphers: SqlRow[];
|
||||
attachments: SqlRow[];
|
||||
webauthn_credentials?: SqlRow[];
|
||||
trusted_two_factor_device_tokens?: SqlRow[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +107,23 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
||||
return (result.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||
const sanitized: SqlRow[] = [];
|
||||
for (const row of rows) {
|
||||
const key = String(row.key || '').trim();
|
||||
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
|
||||
|
||||
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push({ ...row });
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
@@ -152,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) {
|
||||
@@ -189,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');
|
||||
}
|
||||
|
||||
@@ -201,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) {
|
||||
@@ -264,9 +358,12 @@ export function validateBackupPayloadContents(
|
||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
|
||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
|
||||
const externalAttachmentKeys = new Set<string>(
|
||||
options.allowExternalAttachmentBlobs
|
||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||
@@ -294,6 +391,18 @@ export function validateBackupPayloadContents(
|
||||
}
|
||||
}
|
||||
|
||||
const domainSettingUserIds = new Set<string>();
|
||||
for (const row of domainSettingsRows) {
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!userId || !userIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
|
||||
}
|
||||
if (domainSettingUserIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
|
||||
}
|
||||
domainSettingUserIds.add(userId);
|
||||
}
|
||||
|
||||
const folderIds = new Set<string>();
|
||||
for (const row of folderRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
@@ -319,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`;
|
||||
@@ -327,6 +436,38 @@ export function validateBackupPayloadContents(
|
||||
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const accountPasskeyIds = new Set<string>();
|
||||
const accountPasskeyCredentialIds = new Set<string>();
|
||||
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 || (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}`);
|
||||
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
|
||||
accountPasskeyIds.add(id);
|
||||
accountPasskeyCredentialIds.add(credentialId);
|
||||
}
|
||||
|
||||
const trustedTwoFactorTokens = new Set<string>();
|
||||
for (const row of trustedTwoFactorTokenRows) {
|
||||
const token = String(row.token || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const deviceIdentifier = String(row.device_identifier || '').trim();
|
||||
const expiresAt = Number(row.expires_at || 0);
|
||||
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
|
||||
}
|
||||
if (trustedTwoFactorTokens.has(token)) {
|
||||
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
|
||||
}
|
||||
trustedTwoFactorTokens.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBackupArchive(
|
||||
@@ -345,14 +486,18 @@ export async function buildBackupArchive(
|
||||
includeAttachments,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||
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, api_key, 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, 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);
|
||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
@@ -371,12 +516,15 @@ export async function buildBackupArchive(
|
||||
appVersion: APP_VERSION,
|
||||
storageKind: getBlobStorageKind(env),
|
||||
tableCounts: {
|
||||
config: configRows.length,
|
||||
config: exportedConfigRows.length,
|
||||
users: userRows.length,
|
||||
domain_settings: domainSettingsRows.length,
|
||||
user_revisions: revisionRows.length,
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: exportedAttachmentRows.length,
|
||||
webauthn_credentials: accountPasskeyRows.length,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: includeAttachments,
|
||||
@@ -392,12 +540,15 @@ export async function buildBackupArchive(
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||
'db.json': encoder.encode(JSON.stringify({
|
||||
config: configRows,
|
||||
config: exportedConfigRows,
|
||||
users: userRows,
|
||||
domain_settings: domainSettingsRows,
|
||||
user_revisions: revisionRows,
|
||||
folders: folderRows,
|
||||
ciphers: cipherRows,
|
||||
attachments: exportedAttachmentRows,
|
||||
webauthn_credentials: accountPasskeyRows,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
|
||||
+253
-36
@@ -16,6 +16,7 @@ import {
|
||||
type BackupRuntimeState,
|
||||
type BackupScheduleConfig,
|
||||
type BackupSettings,
|
||||
type S3BackupAddressingStyle,
|
||||
type S3BackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
@@ -25,7 +26,9 @@ import {
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
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 {
|
||||
@@ -35,6 +38,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup-schema';
|
||||
@@ -64,6 +68,114 @@ function normalizePath(value: unknown): string {
|
||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeHostnameForPolicy(hostname: string): string {
|
||||
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function parseIpv4Address(hostname: string): number[] | null {
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => {
|
||||
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||
const value = Number(part);
|
||||
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||
});
|
||||
return octets.every((value) => value >= 0) ? octets : null;
|
||||
}
|
||||
|
||||
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||
const [a, b, c] = octets;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && (b === 0 || b === 168)) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hostname: string): boolean {
|
||||
if (!hostname.includes(':')) return false;
|
||||
const normalized = hostname.toLowerCase();
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||
if (mappedIpv4) {
|
||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||
return !octets || isBlockedIpv4Address(octets);
|
||||
}
|
||||
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
|
||||
const firstHextet = Number.parseInt(firstHextetText, 16);
|
||||
if (!Number.isFinite(firstHextet)) return true;
|
||||
return (
|
||||
firstHextet === 0 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xff00) === 0xff00 ||
|
||||
normalized.startsWith('2001:db8:')
|
||||
);
|
||||
}
|
||||
|
||||
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||
const normalized = normalizeHostnameForPolicy(hostname);
|
||||
if (!normalized) throw new Error(`${label} host is required`);
|
||||
if (
|
||||
normalized === 'localhost' ||
|
||||
normalized === 'localhost.localdomain' ||
|
||||
normalized.endsWith('.localhost.localdomain') ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized.endsWith('.home.arpa') ||
|
||||
normalized.endsWith('.internal') ||
|
||||
normalized.endsWith('.lan') ||
|
||||
normalized === 'metadata.google.internal' ||
|
||||
normalized === 'localtest.me' ||
|
||||
normalized.endsWith('.localtest.me') ||
|
||||
normalized === 'lvh.me' ||
|
||||
normalized.endsWith('.lvh.me') ||
|
||||
normalized === 'vcap.me' ||
|
||||
normalized.endsWith('.vcap.me') ||
|
||||
normalized === 'nip.io' ||
|
||||
normalized.endsWith('.nip.io') ||
|
||||
normalized === 'sslip.io' ||
|
||||
normalized.endsWith('.sslip.io') ||
|
||||
normalized === 'xip.io' ||
|
||||
normalized.endsWith('.xip.io')
|
||||
) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
const ipv4 = parseIpv4Address(normalized);
|
||||
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
if (isBlockedIpv6Address(normalized)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must start with http:// or https://`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error(`${label} must not include credentials`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${label} must not include query or fragment`);
|
||||
}
|
||||
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function assertValidTimeZone(timezone: string): string {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||
@@ -109,6 +221,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
const bucket = asTrimmedString(source.bucket);
|
||||
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||
const addressingStyle: S3BackupAddressingStyle =
|
||||
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||
const region = asTrimmedString(source.region) || 'auto';
|
||||
@@ -116,7 +231,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('S3 bucket is required');
|
||||
@@ -129,8 +244,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||
bucket,
|
||||
addressingStyle,
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
@@ -147,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
|
||||
if (!allowIncomplete || baseUrl) {
|
||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||
}
|
||||
if (!allowIncomplete || username) {
|
||||
if (!username) throw new Error('WebDAV username is required');
|
||||
@@ -157,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||
username,
|
||||
password,
|
||||
remotePath,
|
||||
@@ -173,6 +289,32 @@ function normalizeDestination(
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
function shouldPreserveBackupSecret(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
const raw = String(value);
|
||||
return raw === '' || raw === REDACTED_BACKUP_SECRET;
|
||||
}
|
||||
|
||||
function withPreservedDestinationSecret(
|
||||
destinationType: BackupDestinationType,
|
||||
inputDestination: unknown,
|
||||
previous: BackupDestinationRecord | undefined
|
||||
): unknown {
|
||||
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
|
||||
if (destinationType === 's3') {
|
||||
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
|
||||
source.secretAccessKey = previousDestination?.secretAccessKey || '';
|
||||
}
|
||||
} else {
|
||||
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.password)) {
|
||||
source.password = previousDestination?.password || '';
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const asIso = (input: unknown): string | null => {
|
||||
@@ -243,7 +385,11 @@ function normalizeDestinationRecord(
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
|
||||
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||
const destination = normalizeDestination(
|
||||
type,
|
||||
withPreservedDestinationSecret(type, input.destination, previous),
|
||||
!schedule.enabled
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -318,6 +464,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
|
||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||
}
|
||||
|
||||
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: normalizeRuntime(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeRuntimeState(settings: BackupSettings): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(
|
||||
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
|
||||
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
|
||||
const entries = Object.entries(parsed.destinations || {})
|
||||
.filter(([id]) => !!asTrimmedString(id))
|
||||
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
|
||||
return new Map(entries);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||
}
|
||||
@@ -381,27 +568,55 @@ export function normalizeBackupSettingsInput(
|
||||
}
|
||||
|
||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||
return JSON.stringify(settings);
|
||||
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> => (
|
||||
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||
);
|
||||
if (!raw) {
|
||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
|
||||
} catch {
|
||||
throw new Error('Backup settings need administrator reactivation after restore');
|
||||
}
|
||||
@@ -409,15 +624,29 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
|
||||
|
||||
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||
const users = await storage.getAllUsers();
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||
await saveBackupRuntimeStates(storage, settings);
|
||||
}
|
||||
|
||||
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
|
||||
}
|
||||
|
||||
export async function updateBackupDestinationRuntime(
|
||||
storage: StorageService,
|
||||
destinationId: string,
|
||||
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
|
||||
): Promise<BackupRuntimeState> {
|
||||
const runtimes = await loadBackupRuntimeStates(storage);
|
||||
const current = runtimes.get(destinationId) || normalizeRuntime(null);
|
||||
const next = normalizeRuntime(mutator(current));
|
||||
runtimes.set(destinationId, next);
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(runtimes.entries()),
|
||||
}));
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||
@@ -442,12 +671,6 @@ export async function normalizeImportedBackupSettingsValue(
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
} catch {
|
||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||
@@ -455,12 +678,6 @@ export async function normalizeImportedBackupSettingsValue(
|
||||
}
|
||||
}
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
}
|
||||
|
||||
@@ -609,9 +826,9 @@ export function hasBackupSlotBetween(
|
||||
const endMs = endExclusive.getTime();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dayCursor = new Date(startMs);
|
||||
@@ -633,7 +850,7 @@ export function hasBackupSlotBetween(
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) continue;
|
||||
if (lastSuccessMs >= slotStartMs) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -650,9 +867,9 @@ export function isBackupDueNow(
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||
@@ -665,7 +882,7 @@ export function isBackupDueNow(
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) return false;
|
||||
if (lastSuccessMs >= slotStartMs) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -4,15 +4,29 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
isSafeBackupAttachmentBlobName,
|
||||
parseBackupArchive,
|
||||
validateBackupPayloadContents,
|
||||
} from './backup-archive';
|
||||
|
||||
// CONTRACT:
|
||||
// Restore is intentionally whitelist-based. Old backups may contain retired
|
||||
// fields, but only the columns listed here are imported. Keep this file in sync
|
||||
// with src/services/backup-archive.ts whenever backup contents change.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
|
||||
// shadow-table count validation, insert column lists, and frontend import
|
||||
// count types together.
|
||||
// - Do not import users.api_key, even if an older backup contains it.
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
type BackupTableName =
|
||||
| 'config'
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'trusted_two_factor_device_tokens'
|
||||
| 'webauthn_credentials'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
@@ -20,7 +34,10 @@ type BackupTableName =
|
||||
const BACKUP_TABLES: BackupTableName[] = [
|
||||
'config',
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'trusted_two_factor_device_tokens',
|
||||
'webauthn_credentials',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
@@ -35,7 +52,10 @@ export interface BackupImportResultBody {
|
||||
imported: {
|
||||
config: number;
|
||||
users: number;
|
||||
domainSettings: number;
|
||||
userRevisions: number;
|
||||
trustedTwoFactorDeviceTokens: number;
|
||||
webauthnCredentials: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
@@ -155,6 +175,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
||||
'DELETE FROM attachments',
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM webauthn_credentials',
|
||||
'DELETE FROM trusted_two_factor_device_tokens',
|
||||
'DELETE FROM domain_settings',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
'DELETE FROM config',
|
||||
@@ -231,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) => {
|
||||
@@ -275,8 +302,15 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||
...row,
|
||||
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||
})),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
@@ -436,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(
|
||||
@@ -594,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', 'api_key', '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 || []
|
||||
)
|
||||
);
|
||||
@@ -603,6 +648,37 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
tableName('user_revisions'),
|
||||
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('domain_settings'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('domain_settings'),
|
||||
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
|
||||
payload.domain_settings || [],
|
||||
true
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('trusted_two_factor_device_tokens'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('trusted_two_factor_device_tokens'),
|
||||
['token', 'user_id', 'device_identifier', 'expires_at'],
|
||||
payload.trusted_two_factor_device_tokens || []
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
['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 || []
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('folders'),
|
||||
@@ -669,7 +745,10 @@ export async function importBackupArchiveBytes(
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
@@ -690,7 +769,10 @@ export async function importBackupArchiveBytes(
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -729,7 +811,10 @@ export async function importBackupArchiveBytes(
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -804,7 +889,10 @@ export async function importRemoteBackupArchiveBytes(
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
@@ -825,7 +913,10 @@ export async function importRemoteBackupArchiveBytes(
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
@@ -870,7 +961,10 @@ export async function importRemoteBackupArchiveBytes(
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
// CONTRACT:
|
||||
// Backup settings contain provider credentials. They are stored as a v2 envelope:
|
||||
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
|
||||
// server's scheduled backup runner.
|
||||
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||
// active admin public keys so settings can be repaired after restore/migration.
|
||||
// Historical/imported databases may not have usable admin public keys; in that
|
||||
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
|
||||
//
|
||||
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||
// pattern or a deliberately documented replacement. Do not store provider
|
||||
// secrets as plain config JSON.
|
||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||
const RUNTIME_INFO = 'runtime';
|
||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||
@@ -155,6 +167,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
|
||||
}
|
||||
}
|
||||
|
||||
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) return null;
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
portableOnly: true,
|
||||
runtime: {
|
||||
iv: '',
|
||||
ciphertext: '',
|
||||
},
|
||||
portable: envelope.portable,
|
||||
});
|
||||
}
|
||||
|
||||
export async function encryptBackupSettingsEnvelope(
|
||||
plaintext: string,
|
||||
env: Env,
|
||||
@@ -162,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const eligibleUsers = getEligiblePortableUsers(users);
|
||||
if (!eligibleUsers.length) {
|
||||
throw new Error('No active administrator public keys are available for backup settings recovery');
|
||||
}
|
||||
|
||||
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||
@@ -181,6 +204,7 @@ export async function encryptBackupSettingsEnvelope(
|
||||
|
||||
const wraps: BackupSettingsPortableWrap[] = [];
|
||||
for (const user of eligibleUsers) {
|
||||
try {
|
||||
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||
const wrappedKey = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
@@ -193,6 +217,9 @@ export async function encryptBackupSettingsEnvelope(
|
||||
userId: user.id,
|
||||
wrappedKey: bytesToBase64(wrappedKey),
|
||||
});
|
||||
} catch {
|
||||
// Keep runtime settings usable even if an imported admin key is malformed.
|
||||
}
|
||||
}
|
||||
|
||||
const envelope: BackupSettingsEnvelopeV2 = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BackupDestinationType,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
normalizeBackupEndpointUrl,
|
||||
} from './backup-config';
|
||||
|
||||
export interface BackupUploadResult {
|
||||
@@ -33,6 +34,13 @@ export interface RemoteBackupFile {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface RemoteBackupFileStat {
|
||||
provider: BackupDestinationType;
|
||||
remotePath: string;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteBackupFilePutOptions {
|
||||
contentType?: string;
|
||||
}
|
||||
@@ -208,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;
|
||||
@@ -216,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');
|
||||
@@ -245,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}`);
|
||||
}
|
||||
}
|
||||
@@ -268,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;
|
||||
}
|
||||
@@ -433,6 +441,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
|
||||
}
|
||||
|
||||
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
||||
return (await statWebDavFile(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remotePath = webDavFullPath(config, relativePath);
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||
@@ -441,15 +453,40 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (response.status === 404) return false;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 'webdav',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||
const hostname = endpoint.hostname.toLowerCase();
|
||||
const bucketName = bucket.trim().toLowerCase();
|
||||
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||
}
|
||||
|
||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||
const bucket = config.bucket.trim();
|
||||
|
||||
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||
}
|
||||
|
||||
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||
}
|
||||
|
||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||
@@ -482,7 +519,7 @@ async function signedS3Request(
|
||||
config.region || 'auto'
|
||||
);
|
||||
|
||||
return fetch(url.toString(), {
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
@@ -501,7 +538,7 @@ async function putToS3(
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -521,10 +558,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
let continuationToken = '';
|
||||
|
||||
do {
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
@@ -532,8 +575,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
@@ -577,6 +618,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
||||
});
|
||||
}
|
||||
|
||||
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||
} while (continuationToken);
|
||||
|
||||
const deduped = new Map<string, RemoteBackupItem>();
|
||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||
|
||||
@@ -594,7 +638,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 download failed: ${response.status}`);
|
||||
@@ -610,7 +654,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
||||
|
||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'DELETE', url);
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`S3 delete failed: ${response.status}`);
|
||||
@@ -618,14 +662,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
||||
}
|
||||
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
return (await statS3File(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return false;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 's3',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
@@ -637,6 +691,7 @@ interface ConfiguredDestinationAdapter {
|
||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
@@ -647,6 +702,7 @@ export interface RemoteBackupTransferSession {
|
||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||
deleteFile(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
@@ -664,6 +720,7 @@ function resolveConfiguredDestinationAdapter(
|
||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
if (destination.type === 's3') {
|
||||
@@ -676,6 +733,7 @@ function resolveConfiguredDestinationAdapter(
|
||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -711,6 +769,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||
|
||||
// CONTRACT:
|
||||
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
|
||||
// the full custom rule list and the derived active equivalent-domain groups:
|
||||
// - custom_equivalent_domains: UI/client rules with id + excluded state.
|
||||
// - equivalent_domains: active groups derived from non-excluded custom rules.
|
||||
// - excluded_global_equivalent_domains: disabled global rule type ids.
|
||||
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
|
||||
// duplicates without a migration and compatibility plan.
|
||||
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||
Type?: unknown;
|
||||
Domains?: unknown;
|
||||
Excluded?: unknown;
|
||||
};
|
||||
|
||||
function normalizeDomain(value: unknown): string {
|
||||
return normalizeEquivalentDomain(value);
|
||||
}
|
||||
|
||||
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||
const type = Number(entry.type ?? entry.Type);
|
||||
if (!Number.isInteger(type)) return null;
|
||||
|
||||
const rawDomains = entry.domains ?? entry.Domains;
|
||||
if (!Array.isArray(rawDomains)) return null;
|
||||
|
||||
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||
if (domains.length < 2) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
domains,
|
||||
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const seen = new Set<number>();
|
||||
const out: GlobalEquivalentDomain[] = [];
|
||||
for (const entry of input) {
|
||||
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||
if (!normalized || seen.has(normalized.type)) continue;
|
||||
seen.add(normalized.type);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||
|
||||
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||
...bitwardenGlobalDomains,
|
||||
...customGlobalDomains,
|
||||
];
|
||||
|
||||
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const groups: string[][] = [];
|
||||
const seenGroups = new Set<string>();
|
||||
for (const group of input) {
|
||||
if (!Array.isArray(group)) continue;
|
||||
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||
if (domains.length < 2) continue;
|
||||
const key = domains.slice().sort().join('\n');
|
||||
if (seenGroups.has(key)) continue;
|
||||
seenGroups.add(key);
|
||||
groups.push(domains);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||
const parent = new Map<string, string>();
|
||||
|
||||
function find(domain: string): string {
|
||||
const current = parent.get(domain);
|
||||
if (!current) {
|
||||
parent.set(domain, domain);
|
||||
return domain;
|
||||
}
|
||||
if (current === domain) return domain;
|
||||
const root = find(current);
|
||||
parent.set(domain, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function union(a: string, b: string): void {
|
||||
const rootA = find(a);
|
||||
const rootB = find(b);
|
||||
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||
}
|
||||
|
||||
for (const group of normalizeEquivalentDomains(input)) {
|
||||
if (group.length < 2) continue;
|
||||
const [first, ...rest] = group;
|
||||
find(first);
|
||||
for (const domain of rest) union(first, domain);
|
||||
}
|
||||
|
||||
const components = new Map<string, string[]>();
|
||||
for (const domain of parent.keys()) {
|
||||
const root = find(domain);
|
||||
const group = components.get(root) || [];
|
||||
group.push(domain);
|
||||
components.set(root, group);
|
||||
}
|
||||
|
||||
return Array.from(components.values())
|
||||
.map((group) => group.sort())
|
||||
.filter((group) => group.length >= 2)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
export function expandCustomEquivalentDomainsWithGlobals(
|
||||
customGroups: string[][],
|
||||
activeGlobalGroups: string[][]
|
||||
): string[][] {
|
||||
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||
if (!normalizedCustomGroups.length) return [];
|
||||
|
||||
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||
return mergeEquivalentDomainGroups([
|
||||
...activeGlobalGroups,
|
||||
...normalizedCustomGroups,
|
||||
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||
}
|
||||
|
||||
function createCustomDomainId(domains: string[], index: number): string {
|
||||
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||
}
|
||||
|
||||
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const rules: CustomEquivalentDomain[] = [];
|
||||
const seenGroups = new Set<string>();
|
||||
for (const [index, item] of input.entries()) {
|
||||
const record = Array.isArray(item)
|
||||
? { domains: item, excluded: false, id: '' }
|
||||
: item && typeof item === 'object'
|
||||
? item as Record<string, unknown>
|
||||
: null;
|
||||
if (!record) continue;
|
||||
|
||||
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||
if (!domains) continue;
|
||||
|
||||
const key = domains.slice().sort().join('\n');
|
||||
if (seenGroups.has(key)) continue;
|
||||
seenGroups.add(key);
|
||||
|
||||
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||
rules.push({
|
||||
id: rawId || createCustomDomainId(domains, index),
|
||||
domains,
|
||||
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||
});
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||
return mergeEquivalentDomainGroups(rules
|
||||
.filter((rule) => !rule.excluded)
|
||||
.map((rule) => rule.domains));
|
||||
}
|
||||
|
||||
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const item of input) {
|
||||
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||
const excluded = typeof item === 'object' && item !== null
|
||||
? Boolean((item as Record<string, unknown>).excluded)
|
||||
: true;
|
||||
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||
seen.add(type);
|
||||
out.push(type);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildDomainsResponse(
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: CustomEquivalentDomain[],
|
||||
excludedGlobalEquivalentDomains: number[],
|
||||
options: { omitExcludedGlobals?: boolean } = {}
|
||||
): DomainRulesResponse {
|
||||
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||
const activeGlobalDomainGroups = globalDomains
|
||||
.filter((entry) => !excluded.has(entry.type))
|
||||
.map((entry) => entry.domains);
|
||||
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||
equivalentDomains,
|
||||
activeGlobalDomainGroups
|
||||
);
|
||||
const globals = globalDomains
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
domains: entry.domains,
|
||||
excluded: excluded.has(entry.type),
|
||||
}))
|
||||
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||
|
||||
return {
|
||||
equivalentDomains: mergedEquivalentDomains,
|
||||
customEquivalentDomains,
|
||||
globalEquivalentDomains: globals,
|
||||
object: 'domains',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
setConfigValue as saveConfigValue,
|
||||
} from './storage-config-repo';
|
||||
|
||||
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
|
||||
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
|
||||
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
|
||||
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
|
||||
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
|
||||
const PUSH_REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
interface CachedPushAccessToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedPushAccessToken: CachedPushAccessToken | null = null;
|
||||
|
||||
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
console.error(errorMessage, error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function randomInstallationEmail(): string {
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||
return `${localPart}@nodewarden.app`;
|
||||
}
|
||||
|
||||
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
|
||||
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const [id, key] = await Promise.all([
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
|
||||
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
|
||||
]);
|
||||
const normalizedId = String(id || '').trim();
|
||||
const normalizedKey = String(key || '').trim();
|
||||
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
|
||||
}
|
||||
|
||||
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||
const existing = await getPushInstallationCredentials(db);
|
||||
if (existing) return existing;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
INSTALLATIONS_URI,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: randomInstallationEmail(),
|
||||
}),
|
||||
},
|
||||
'Failed to request Bitwarden push installation:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { id?: string; 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;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
|
||||
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
|
||||
]);
|
||||
return { id, key };
|
||||
}
|
||||
|
||||
async function getPushAccessToken(env: Env): Promise<string | null> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
|
||||
return cachedPushAccessToken.token;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'api.push',
|
||||
client_id: `installation.${credentials.id}`,
|
||||
client_secret: credentials.key,
|
||||
});
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_IDENTITY_URI}/connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
},
|
||||
'Failed to get Bitwarden push relay token:'
|
||||
);
|
||||
if (!response) return null;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
|
||||
const token = String(body?.access_token || '').trim();
|
||||
if (!token) {
|
||||
console.error('Bitwarden push relay token response did not include an access_token');
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
|
||||
cachedPushAccessToken = {
|
||||
token,
|
||||
expiresAt: now + Math.floor(expiresInSeconds * 500),
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
|
||||
const token = await getPushAccessToken(env);
|
||||
if (!token) return false;
|
||||
|
||||
const response = await fetchPushEndpoint(
|
||||
`${PUSH_RELAY_URI}${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
},
|
||||
`Bitwarden push relay request failed: ${path}`
|
||||
);
|
||||
if (!response) return false;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||
const source = payload || {};
|
||||
const id = source.Id ?? source.id;
|
||||
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
|
||||
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
|
||||
|
||||
if (id != null) {
|
||||
return {
|
||||
id,
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
organizationId,
|
||||
collectionIds,
|
||||
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
userId: source.UserId ?? source.userId ?? userId,
|
||||
date: source.Date ?? source.date ?? revisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerMobilePushDevice(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
type: number;
|
||||
pushUuid: string;
|
||||
pushToken: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||
if (!credentials) return false;
|
||||
|
||||
return postToPushRelay(env, '/push/register', {
|
||||
deviceId: input.pushUuid,
|
||||
pushToken: input.pushToken,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
identifier: input.deviceIdentifier,
|
||||
installationId: credentials.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||
const normalized = String(pushUuid || '').trim();
|
||||
if (!normalized) return false;
|
||||
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||
}
|
||||
|
||||
export async function notifyMobilePush(
|
||||
env: Env,
|
||||
input: {
|
||||
userId: string;
|
||||
updateType: number;
|
||||
revisionDate: string;
|
||||
contextId: string | null;
|
||||
payload: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
): Promise<void> {
|
||||
const hasPushDevice = await env.DB
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(input.userId, '')
|
||||
.first<{ '1': number }>();
|
||||
if (!hasPushDevice) return;
|
||||
|
||||
let actingPushUuid: string | null = null;
|
||||
if (input.contextId) {
|
||||
const row = await env.DB
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(input.userId, input.contextId)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
actingPushUuid = row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
await postToPushRelay(env, '/push/send', {
|
||||
userId: input.userId,
|
||||
organizationId: null,
|
||||
deviceId: actingPushUuid,
|
||||
identifier: input.contextId,
|
||||
type: input.updateType,
|
||||
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
|
||||
clientType: null,
|
||||
installationId: null,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||
|
||||
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||
|
||||
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 ''" },
|
||||
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'type', sql: 'type TEXT' },
|
||||
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
|
||||
{ name: 'transports', sql: 'transports TEXT' },
|
||||
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
|
||||
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
|
||||
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
|
||||
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
|
||||
] as const;
|
||||
|
||||
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
|
||||
'challenge_hash',
|
||||
'scope',
|
||||
'user_id',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'created_at',
|
||||
] as const;
|
||||
|
||||
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
|
||||
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
|
||||
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
if (accountPasskeySchemaReady) return;
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'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, 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)'
|
||||
)
|
||||
.run();
|
||||
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
|
||||
if (!credentialColumns.has(column.name)) {
|
||||
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
|
||||
}
|
||||
}
|
||||
credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||
if (!credentialColumns.has('credential_id')) {
|
||||
throw new Error('webauthn_credentials schema is missing credential_id');
|
||||
}
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
|
||||
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
|
||||
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
|
||||
if (!challengeSchemaComplete) {
|
||||
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||
)
|
||||
.run();
|
||||
}
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
|
||||
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
|
||||
|
||||
accountPasskeySchemaReady = true;
|
||||
}
|
||||
|
||||
function parseTransports(value: string | null): string[] | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
purpose?: string | null;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
counter: number;
|
||||
type: string | null;
|
||||
aa_guid: string | null;
|
||||
transports: string | null;
|
||||
encrypted_user_key: string | null;
|
||||
encrypted_public_key: string | null;
|
||||
encrypted_private_key: string | null;
|
||||
supports_prf: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): AccountPasskeyCredential {
|
||||
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,
|
||||
counter: Number(row.counter || 0),
|
||||
type: row.type ?? null,
|
||||
aaGuid: row.aa_guid ?? null,
|
||||
transports: parseTransports(row.transports),
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
supportsPrf: !!row.supports_prf,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapChallengeRow(row: {
|
||||
challenge_hash: string;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
user_id: string | null;
|
||||
expires_at: number;
|
||||
used_at: number | null;
|
||||
created_at: number;
|
||||
}): AccountPasskeyChallenge {
|
||||
return {
|
||||
challengeHash: row.challenge_hash,
|
||||
scope: row.scope,
|
||||
userId: row.user_id ?? null,
|
||||
expiresAt: Number(row.expires_at || 0),
|
||||
usedAt: row.used_at == null ? null : Number(row.used_at),
|
||||
createdAt: Number(row.created_at || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
safeBind: SafeBindFn,
|
||||
credential: AccountPasskeyCredential
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'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,
|
||||
credential.counter,
|
||||
credential.type,
|
||||
credential.aaGuid,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
credential.encryptedUserKey,
|
||||
credential.encryptedPublicKey,
|
||||
credential.encryptedPrivateKey,
|
||||
credential.supportsPrf ? 1 : 0,
|
||||
credential.createdAt,
|
||||
credential.updatedAt
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.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);
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialById(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
|
||||
.bind(userId, id)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyCredentialByCredentialId(
|
||||
db: D1Database,
|
||||
credentialId: string
|
||||
): Promise<AccountPasskeyCredential | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
|
||||
.bind(credentialId)
|
||||
.first<any>();
|
||||
return row ? mapCredentialRow(row) : null;
|
||||
}
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
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 = ? AND purpose = ?')
|
||||
.bind(userId, purpose)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyCounter(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
updatedAt: string
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db
|
||||
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
|
||||
.bind(counter, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateAccountPasskeyEncryption(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
encryptedUserKey: string,
|
||||
encryptedPublicKey: string,
|
||||
encryptedPrivateKey: string,
|
||||
updatedAt: string
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
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 = ? AND purpose = 'login'"
|
||||
)
|
||||
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: 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 = ? AND purpose = ?')
|
||||
.bind(userId, id, purpose)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function saveAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challenge: AccountPasskeyChallenge
|
||||
): Promise<void> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
|
||||
)
|
||||
.bind(
|
||||
challenge.challengeHash,
|
||||
challenge.scope,
|
||||
challenge.userId,
|
||||
challenge.expiresAt,
|
||||
challenge.usedAt,
|
||||
challenge.createdAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function consumeAccountPasskeyChallenge(
|
||||
db: D1Database,
|
||||
challengeHash: string,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
userId: string | null,
|
||||
nowMs: number
|
||||
): Promise<AccountPasskeyChallenge | null> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
|
||||
.bind(challengeHash, scope)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
const challenge = mapChallengeRow(row);
|
||||
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
|
||||
if (userId !== null && challenge.userId !== userId) return null;
|
||||
if (userId === null && challenge.userId !== null) return null;
|
||||
|
||||
const result = await db
|
||||
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
|
||||
.bind(nowMs, challengeHash)
|
||||
.run();
|
||||
if (Number(result.meta.changes || 0) <= 0) return null;
|
||||
return { ...challenge, usedAt: nowMs };
|
||||
}
|
||||
@@ -1,5 +1,72 @@
|
||||
import type { AuditLog, Invite } from '../types';
|
||||
|
||||
export interface AuditLogListOptions {
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: string | null;
|
||||
level?: string | null;
|
||||
q?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogListResult {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
function auditLogFromRow(row: any): AuditLog {
|
||||
return {
|
||||
id: row.id,
|
||||
actorUserId: row.actor_user_id ?? null,
|
||||
actorEmail: row.actor_email ?? null,
|
||||
action: row.action,
|
||||
category: row.category || 'system',
|
||||
level: row.level || 'info',
|
||||
targetType: row.target_type ?? null,
|
||||
targetId: row.target_id ?? null,
|
||||
targetUserEmail: row.target_user_email ?? null,
|
||||
metadata: row.metadata ?? null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (options.from) {
|
||||
conditions.push('l.created_at >= ?');
|
||||
params.push(options.from);
|
||||
}
|
||||
if (options.to) {
|
||||
conditions.push('l.created_at <= ?');
|
||||
params.push(options.to);
|
||||
}
|
||||
if (options.category) {
|
||||
conditions.push('l.category = ?');
|
||||
params.push(options.category);
|
||||
}
|
||||
if (options.level) {
|
||||
conditions.push('l.level = ?');
|
||||
params.push(options.level);
|
||||
}
|
||||
if (options.q) {
|
||||
const q = options.q.toLowerCase().slice(0, 48);
|
||||
const like = `%${q}%`;
|
||||
conditions.push(
|
||||
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
|
||||
);
|
||||
params.push(like, like, like, like, like, like);
|
||||
}
|
||||
|
||||
return {
|
||||
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
@@ -50,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
|
||||
}
|
||||
|
||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
"UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(userId, now, code, now)
|
||||
.bind(now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||
.prepare(
|
||||
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(userId, now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM invites WHERE code = ?')
|
||||
.bind(code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
|
||||
.bind(now)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM invites').run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
@@ -77,8 +176,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
|
||||
.bind(beforeIso)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
|
||||
const limit = Math.max(1, Math.floor(maxEntries));
|
||||
const result = await db
|
||||
.prepare(
|
||||
'DELETE FROM audit_logs WHERE id IN (' +
|
||||
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
|
||||
')'
|
||||
)
|
||||
.bind(limit)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function clearAuditLogs(db: D1Database): Promise<number> {
|
||||
const result = await db.prepare('DELETE FROM audit_logs').run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
|
||||
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
||||
const offset = Math.max(0, Math.floor(options.offset || 0));
|
||||
const { where, params } = buildAuditWhere(options);
|
||||
|
||||
const rows = await db
|
||||
.prepare(
|
||||
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
|
||||
'FROM audit_logs l ' +
|
||||
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
|
||||
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
|
||||
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.bind(...params, limit + 1, offset)
|
||||
.all<any>();
|
||||
const results = rows.results || [];
|
||||
const logs = results.slice(0, limit).map(auditLogFromRow);
|
||||
const hasMore = results.length > limit;
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: offset + logs.length + (hasMore ? 1 : 0),
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AuthRequestRecord, AuthRequestType } from '../types';
|
||||
|
||||
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
|
||||
|
||||
function mapAuthRequestRow(row: any): AuthRequestRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
organizationId: row.organization_id ?? null,
|
||||
type: Number(row.type) as AuthRequestType,
|
||||
requestDeviceIdentifier: row.request_device_identifier,
|
||||
requestDeviceType: Number(row.request_device_type ?? 14),
|
||||
requestIpAddress: row.request_ip_address ?? null,
|
||||
requestCountryName: row.request_country_name ?? null,
|
||||
responseDeviceIdentifier: row.response_device_identifier ?? null,
|
||||
accessCode: row.access_code,
|
||||
publicKey: row.public_key,
|
||||
key: row.key ?? null,
|
||||
masterPasswordHash: row.master_password_hash ?? null,
|
||||
approved: row.approved == null ? null : Number(row.approved) === 1,
|
||||
creationDate: row.creation_date,
|
||||
responseDate: row.response_date ?? null,
|
||||
authenticationDate: row.authentication_date ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
|
||||
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
|
||||
}
|
||||
|
||||
const AUTH_REQUEST_SELECT =
|
||||
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
|
||||
'FROM auth_requests';
|
||||
|
||||
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO auth_requests(' +
|
||||
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
request.id,
|
||||
request.userId,
|
||||
request.organizationId,
|
||||
request.type,
|
||||
request.requestDeviceIdentifier,
|
||||
request.requestDeviceType,
|
||||
request.requestIpAddress,
|
||||
request.requestCountryName,
|
||||
request.responseDeviceIdentifier,
|
||||
request.accessCode,
|
||||
request.publicKey,
|
||||
request.key,
|
||||
request.masterPasswordHash,
|
||||
request.approved == null ? null : (request.approved ? 1 : 0),
|
||||
request.creationDate,
|
||||
request.responseDate,
|
||||
request.authenticationDate
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
|
||||
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
|
||||
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);
|
||||
}
|
||||
|
||||
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
|
||||
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
|
||||
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
|
||||
'FROM auth_requests ar ' +
|
||||
'JOIN (' +
|
||||
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
|
||||
' FROM auth_requests ' +
|
||||
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
|
||||
' GROUP BY request_device_identifier' +
|
||||
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
|
||||
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
|
||||
'ORDER BY ar.creation_date DESC'
|
||||
)
|
||||
.bind(userId, cutoff, userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
|
||||
}
|
||||
|
||||
export async function updateAuthRequestResponse(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
userId: string,
|
||||
update: {
|
||||
approved: boolean;
|
||||
responseDeviceIdentifier: string;
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
responseDate?: string;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
|
||||
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
|
||||
)
|
||||
.bind(
|
||||
update.approved ? 1 : 0,
|
||||
update.responseDeviceIdentifier,
|
||||
update.approved ? (update.key ?? null) : null,
|
||||
update.approved ? (update.masterPasswordHash ?? null) : null,
|
||||
update.responseDate || new Date().toISOString(),
|
||||
id,
|
||||
userId
|
||||
)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
|
||||
.bind(authenticationDate, id)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
|
||||
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'favorite',
|
||||
'reprompt',
|
||||
'key',
|
||||
'attachments',
|
||||
'Attachments',
|
||||
'attachments2',
|
||||
'Attachments2',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
@@ -83,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
|
||||
};
|
||||
} catch {
|
||||
console.error('Corrupted cipher data, id:', row.id);
|
||||
@@ -103,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);
|
||||
@@ -110,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,
|
||||
@@ -240,7 +253,9 @@ export async function getCiphersPage(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Cipher[]> {
|
||||
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
||||
const whereDeleted = includeDeleted
|
||||
? ''
|
||||
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
|
||||
const res = await db
|
||||
.prepare(
|
||||
`SELECT ${selectCipherColumns()} FROM ciphers
|
||||
@@ -337,7 +352,10 @@ export async function bulkArchiveCiphers(
|
||||
`UPDATE ciphers
|
||||
SET archived_at = ?, updated_at = ?,
|
||||
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||
WHERE user_id = ? AND id IN (${placeholders})
|
||||
AND deleted_at IS NULL
|
||||
AND json_extract(data, '$.deletedAt') IS NULL
|
||||
AND json_extract(data, '$.deletedDate') IS NULL`
|
||||
)
|
||||
.bind(now, now, userId, ...chunk)
|
||||
.run();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
pushUuid: row.push_uuid ?? null,
|
||||
pushToken: row.push_token ?? null,
|
||||
lastSeenAt: row.last_seen_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -38,13 +41,15 @@ export async function upsertDevice(
|
||||
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
|
||||
'last_seen_at=excluded.last_seen_at, ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
@@ -57,6 +62,7 @@ export async function upsertDevice(
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
effectivePushUuid,
|
||||
existingDevice?.deviceNote ?? null,
|
||||
now,
|
||||
now,
|
||||
@@ -91,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,
|
||||
@@ -166,7 +186,7 @@ export async function isKnownDeviceByEmail(
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
@@ -177,7 +197,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
@@ -185,6 +205,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
|
||||
return row ? mapDeviceRow(row) : null;
|
||||
}
|
||||
|
||||
export async function updateDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
pushUuid: string,
|
||||
pushToken: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
|
||||
'WHERE user_id = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearDevicePushToken(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<{ pushUuid: string | null } | null> {
|
||||
const existing = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
if (!existing) return null;
|
||||
|
||||
await db
|
||||
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(new Date().toISOString(), userId, deviceIdentifier)
|
||||
.run();
|
||||
|
||||
return { pushUuid: existing.push_uuid ?? null };
|
||||
}
|
||||
|
||||
export async function getDevicePushUuid(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<string | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ push_uuid: string | null }>();
|
||||
return row?.push_uuid ?? null;
|
||||
}
|
||||
|
||||
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||
.bind(userId, '')
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||
@@ -233,6 +310,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function updateTrustedTwoFactorTokensExpiryByDevice(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs: number
|
||||
): Promise<number> {
|
||||
const now = Date.now();
|
||||
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||
const result = await db
|
||||
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
|
||||
.bind(expiresAtMs, userId, deviceIdentifier, now)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0);
|
||||
}
|
||||
|
||||
export async function saveTrustedTwoFactorDeviceToken(
|
||||
db: D1Database,
|
||||
trustedTokenKey: TrustedTokenKeyFn,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { UserDomainSettings } from '../types';
|
||||
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||
|
||||
// Storage adapter for the domain_settings table.
|
||||
//
|
||||
// CONTRACT:
|
||||
// equivalent_domains is kept as the active derived groups for compatibility and
|
||||
// fallback reads. custom_equivalent_domains is the full rule list that preserves
|
||||
// UI/client state. Save both together through saveUserDomainSettings().
|
||||
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||
const row = await db
|
||||
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.first<{
|
||||
equivalent_domains: string | null;
|
||||
custom_equivalent_domains: string | null;
|
||||
excluded_global_equivalent_domains: string | null;
|
||||
updated_at: string | null;
|
||||
}>();
|
||||
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||
: [];
|
||||
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||
? storedCustomEquivalentDomains
|
||||
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||
|
||||
return {
|
||||
userId,
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||
updatedAt: row?.updated_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveUserDomainSettings(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||
excludedGlobalEquivalentDomains: number[],
|
||||
updatedAt: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||
'updated_at = excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
JSON.stringify(equivalentDomains),
|
||||
JSON.stringify(customEquivalentDomains),
|
||||
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||
updatedAt
|
||||
)
|
||||
.run();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -44,9 +53,14 @@ export async function clearFolderFromCiphers(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id = ?`
|
||||
WHERE user_id = ?
|
||||
AND (
|
||||
folder_id = ?
|
||||
OR json_extract(data, '$.folderId') = ?
|
||||
OR json_extract(data, '$.folder_id') = ?
|
||||
)`
|
||||
)
|
||||
.bind(now, userId, folderId)
|
||||
.bind(now, userId, folderId, folderId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -71,9 +85,14 @@ export async function bulkDeleteFolders(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?,
|
||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||
WHERE user_id = ?
|
||||
AND (
|
||||
folder_id IN (${placeholders})
|
||||
OR json_extract(data, '$.folderId') IN (${placeholders})
|
||||
OR json_extract(data, '$.folder_id') IN (${placeholders})
|
||||
)`
|
||||
)
|
||||
.bind(now, userId, ...chunk)
|
||||
.bind(now, userId, ...chunk, ...chunk, ...chunk)
|
||||
.run();
|
||||
|
||||
await db
|
||||
|
||||
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
|
||||
db: D1Database,
|
||||
refreshTokenKey: RefreshTokenKeyFn,
|
||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||
saveRefreshTokenRecord: (
|
||||
token: string,
|
||||
userId: string,
|
||||
expiresAtMs?: number,
|
||||
deviceIdentifier?: string | null,
|
||||
deviceSessionStamp?: string | null
|
||||
) => Promise<void>,
|
||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||
token: string
|
||||
): Promise<RefreshTokenRecord | null> {
|
||||
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
|
||||
await maybeCleanupExpiredRefreshTokens(now);
|
||||
const tokenKey = await refreshTokenKey(token);
|
||||
|
||||
let row = await db
|
||||
const row = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(tokenKey)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (!row) {
|
||||
const legacyRow = await db
|
||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||
.bind(token)
|
||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||
|
||||
if (legacyRow) {
|
||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
return null;
|
||||
}
|
||||
await saveRefreshTokenRecord(
|
||||
token,
|
||||
legacyRow.user_id,
|
||||
legacyRow.expires_at,
|
||||
legacyRow.device_identifier ?? null,
|
||||
legacyRow.device_session_stamp ?? null
|
||||
);
|
||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||
return {
|
||||
userId: legacyRow.user_id,
|
||||
expiresAt: legacyRow.expires_at,
|
||||
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await deleteRefreshTokenRecord(token);
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
// IMPORTANT:
|
||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||
// Any new table/column/index must be added to both places together.
|
||||
// This is the runtime D1 schema bootstrap. Keep it in sync with
|
||||
// migrations/0001_init.sql. Any new table/column/index must be added to both
|
||||
// places together.
|
||||
//
|
||||
// WHEN CHANGING THIS:
|
||||
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
|
||||
// rerun these idempotent statements.
|
||||
// - If the new table stores persistent data, update the backup export/import
|
||||
// contract in src/services/backup-archive.ts and backup-import.ts.
|
||||
// - Keep statements idempotent; D1 may execute them again on later requests.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
@@ -65,17 +84,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'ALTER TABLE invites ADD COLUMN used_by TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
|
||||
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
|
||||
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
|
||||
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
@@ -84,17 +110,50 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN push_token TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
|
||||
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
|
||||
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, time_counter), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'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, 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)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function consumeTotpLoginCounter(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
userId: string,
|
||||
timeCounter: number,
|
||||
consumedAtMs: number,
|
||||
markerTtlMs: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
|
||||
.bind(consumedAtMs - markerTtlMs)
|
||||
.run();
|
||||
cleanedUpAt = consumedAtMs;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, time_counter) DO NOTHING'
|
||||
)
|
||||
.bind(userId, timeCounter, consumedAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
|
||||
+320
-7
@@ -1,5 +1,6 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { ensurePushInstallationCredentials } from './push-relay';
|
||||
import { ensureStorageSchema } from './storage-schema';
|
||||
import {
|
||||
getConfigValue as getStoredConfigValue,
|
||||
@@ -18,13 +19,21 @@ import {
|
||||
saveUser as saveStoredUser,
|
||||
} from './storage-user-repo';
|
||||
import {
|
||||
type AuditLogListOptions,
|
||||
createAuditLog as createStoredAuditLog,
|
||||
clearAuditLogs as clearStoredAuditLogs,
|
||||
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||
createInvite as createStoredInvite,
|
||||
deleteInvite as deleteStoredInvite,
|
||||
deleteInvalidInvites as deleteStoredInvalidInvites,
|
||||
deleteAllInvites as deleteStoredInvites,
|
||||
getInvite as findStoredInvite,
|
||||
listAuditLogs as listStoredAuditLogs,
|
||||
listInvites as listStoredInvites,
|
||||
markInviteUsed as markStoredInviteUsed,
|
||||
revokeInvite as revokeStoredInvite,
|
||||
pruneAuditLogs as pruneStoredAuditLogs,
|
||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||
revertInviteUsed as revertStoredInviteUsed,
|
||||
} from './storage-admin-repo';
|
||||
import {
|
||||
bulkDeleteFolders as deleteStoredFolders,
|
||||
@@ -32,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';
|
||||
@@ -44,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,
|
||||
@@ -51,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,
|
||||
@@ -66,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,
|
||||
@@ -82,33 +97,73 @@ import {
|
||||
import {
|
||||
deleteDevice as deleteStoredDevice,
|
||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||
clearDevicePushToken as clearStoredDevicePushToken,
|
||||
clearDeviceKeys as clearStoredDeviceKeys,
|
||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||
getDevice as findStoredDevice,
|
||||
getDevicePushUuid as findStoredDevicePushUuid,
|
||||
getDevicesByUserId as listStoredDevicesByUserId,
|
||||
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
||||
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
||||
isKnownDevice as getKnownStoredDevice,
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
|
||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
updateDevicePushToken as updateStoredDevicePushToken,
|
||||
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||
userHasPushDevice as getUserHasPushDevice,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
createAuthRequest as createStoredAuthRequest,
|
||||
getAuthRequestById as findStoredAuthRequestById,
|
||||
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
|
||||
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
|
||||
updateAuthRequestResponse as updateStoredAuthRequestResponse,
|
||||
} from './storage-auth-request-repo';
|
||||
import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||
} from './storage-attachment-token-repo';
|
||||
import {
|
||||
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
|
||||
} from './storage-totp-replay-repo';
|
||||
import {
|
||||
getRevisionDate as getStoredRevisionDate,
|
||||
updateRevisionDate as updateStoredRevisionDate,
|
||||
} from './storage-revision-repo';
|
||||
import {
|
||||
getUserDomainSettings as getStoredUserDomainSettings,
|
||||
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||
} from './storage-domain-rules-repo';
|
||||
import {
|
||||
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
|
||||
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
|
||||
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
|
||||
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
|
||||
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
|
||||
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
|
||||
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
|
||||
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
|
||||
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
|
||||
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
|
||||
} from './storage-account-passkey-repo';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
||||
// IMPORTANT:
|
||||
// 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-07-05-passkey-2fa';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -121,10 +176,13 @@ export class StorageService {
|
||||
private static schemaVerified = false;
|
||||
private static lastRefreshTokenCleanupAt = 0;
|
||||
private static lastAttachmentTokenCleanupAt = 0;
|
||||
private static lastTotpReplayCleanupAt = 0;
|
||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||
|
||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
||||
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
|
||||
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
@@ -139,6 +197,16 @@ export class StorageService {
|
||||
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
||||
}
|
||||
|
||||
private async hasRequiredSchemaTables(): Promise<boolean> {
|
||||
const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
|
||||
const result = await this.db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
||||
.bind(...REQUIRED_SCHEMA_TABLES)
|
||||
.all<{ name: string }>();
|
||||
const found = new Set((result.results || []).map((row) => row.name));
|
||||
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
|
||||
}
|
||||
|
||||
private sqlChunkSize(fixedBindCount: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
@@ -182,10 +250,14 @@ export class StorageService {
|
||||
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
|
||||
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
|
||||
? !(await this.hasRequiredSchemaTables())
|
||||
: true;
|
||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
|
||||
await ensureStorageSchema(this.db);
|
||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||
}
|
||||
await ensurePushInstallationCredentials(this.db);
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
@@ -258,8 +330,20 @@ export class StorageService {
|
||||
return markStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async revokeInvite(code: string): Promise<boolean> {
|
||||
return revokeStoredInvite(this.db, code);
|
||||
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
|
||||
return assignStoredInviteUsedBy(this.db, code, userId);
|
||||
}
|
||||
|
||||
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||
return revertStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async deleteInvite(code: string): Promise<boolean> {
|
||||
return deleteStoredInvite(this.db, code);
|
||||
}
|
||||
|
||||
async deleteInvalidInvites(): Promise<number> {
|
||||
return deleteStoredInvalidInvites(this.db);
|
||||
}
|
||||
|
||||
async deleteAllInvites(): Promise<number> {
|
||||
@@ -270,12 +354,132 @@ export class StorageService {
|
||||
await createStoredAuditLog(this.db, log);
|
||||
}
|
||||
|
||||
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
|
||||
return listStoredAuditLogs(this.db, options);
|
||||
}
|
||||
|
||||
async pruneAuditLogs(beforeIso: string): Promise<number> {
|
||||
return pruneStoredAuditLogs(this.db, beforeIso);
|
||||
}
|
||||
|
||||
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
|
||||
return pruneStoredAuditLogsToMax(this.db, maxEntries);
|
||||
}
|
||||
|
||||
async clearAuditLogs(): Promise<number> {
|
||||
return clearStoredAuditLogs(this.db);
|
||||
}
|
||||
|
||||
// --- Domain rules ---
|
||||
|
||||
async getUserDomainSettings(userId: string) {
|
||||
return getStoredUserDomainSettings(this.db, userId);
|
||||
}
|
||||
|
||||
async saveUserDomainSettings(
|
||||
userId: string,
|
||||
equivalentDomains: string[][],
|
||||
customEquivalentDomains: CustomEquivalentDomain[],
|
||||
excludedGlobalEquivalentDomains: number[]
|
||||
): Promise<void> {
|
||||
await saveStoredUserDomainSettings(
|
||||
this.db,
|
||||
userId,
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
excludedGlobalEquivalentDomains,
|
||||
new Date().toISOString()
|
||||
);
|
||||
await this.updateRevisionDate(userId);
|
||||
}
|
||||
|
||||
// --- Account passkeys / WebAuthn login credentials ---
|
||||
|
||||
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
|
||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||
}
|
||||
|
||||
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> {
|
||||
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
|
||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||
}
|
||||
|
||||
async countAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyCounter(
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
counter: number,
|
||||
updatedAt: string = new Date().toISOString()
|
||||
): Promise<void> {
|
||||
await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyEncryption(
|
||||
userId: string,
|
||||
credentialId: string,
|
||||
encryptedUserKey: string,
|
||||
encryptedPublicKey: string,
|
||||
encryptedPrivateKey: string,
|
||||
updatedAt: string = new Date().toISOString()
|
||||
): Promise<boolean> {
|
||||
return updateStoredAccountPasskeyEncryption(
|
||||
this.db,
|
||||
userId,
|
||||
credentialId,
|
||||
encryptedUserKey,
|
||||
encryptedPublicKey,
|
||||
encryptedPrivateKey,
|
||||
updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
await saveStoredAccountPasskeyChallenge(this.db, challenge);
|
||||
}
|
||||
|
||||
async consumeAccountPasskeyChallenge(
|
||||
challengeHash: string,
|
||||
scope: AccountPasskeyChallengeScope,
|
||||
userId: string | null,
|
||||
nowMs: number = Date.now()
|
||||
): Promise<AccountPasskeyChallenge | null> {
|
||||
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
|
||||
}
|
||||
|
||||
// --- Ciphers ---
|
||||
|
||||
async getCipher(id: string): Promise<Cipher | null> {
|
||||
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);
|
||||
}
|
||||
@@ -326,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);
|
||||
}
|
||||
@@ -364,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);
|
||||
}
|
||||
@@ -372,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);
|
||||
}
|
||||
@@ -392,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);
|
||||
}
|
||||
@@ -432,7 +652,6 @@ export class StorageService {
|
||||
this.db,
|
||||
this.refreshTokenKey.bind(this),
|
||||
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
||||
this.saveRefreshToken.bind(this),
|
||||
this.deleteRefreshToken.bind(this),
|
||||
token
|
||||
);
|
||||
@@ -453,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);
|
||||
}
|
||||
@@ -539,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,
|
||||
@@ -559,6 +786,27 @@ export class StorageService {
|
||||
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async updateDevicePushToken(
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
pushUuid: string,
|
||||
pushToken: string
|
||||
): Promise<boolean> {
|
||||
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
|
||||
}
|
||||
|
||||
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
|
||||
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
|
||||
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async userHasPushDevice(userId: string): Promise<boolean> {
|
||||
return getUserHasPushDevice(this.db, userId);
|
||||
}
|
||||
|
||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
@@ -571,6 +819,49 @@ export class StorageService {
|
||||
return deleteStoredDevicesByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
// --- Auth requests / Login with device ---
|
||||
|
||||
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
|
||||
await createStoredAuthRequest(this.db, request);
|
||||
}
|
||||
|
||||
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||
return listStoredPendingAuthRequestsByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async updateAuthRequestResponse(
|
||||
id: string,
|
||||
userId: string,
|
||||
update: {
|
||||
approved: boolean;
|
||||
responseDeviceIdentifier: string;
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return updateStoredAuthRequestResponse(this.db, id, userId, update);
|
||||
}
|
||||
|
||||
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
|
||||
return markStoredAuthRequestAuthenticated(this.db, id);
|
||||
}
|
||||
|
||||
async pruneExpiredAuthRequests(): Promise<number> {
|
||||
return pruneStoredExpiredAuthRequests(this.db);
|
||||
}
|
||||
|
||||
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||
return listStoredTrustedTokenSummaries(this.db, userId);
|
||||
}
|
||||
@@ -583,6 +874,10 @@ export class StorageService {
|
||||
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
||||
}
|
||||
|
||||
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
|
||||
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
|
||||
}
|
||||
|
||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||
|
||||
async saveTrustedTwoFactorDeviceToken(
|
||||
@@ -599,6 +894,24 @@ export class StorageService {
|
||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
||||
}
|
||||
|
||||
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
|
||||
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
|
||||
const result = await consumeStoredTotpLoginCounter(
|
||||
this.db,
|
||||
this.shouldRunPeriodicCleanup.bind(this),
|
||||
StorageService.lastTotpReplayCleanupAt,
|
||||
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
|
||||
userId,
|
||||
timeCounter,
|
||||
consumedAtMs,
|
||||
StorageService.TOTP_REPLAY_MARKER_TTL_MS
|
||||
);
|
||||
if (result.cleanedUpAt !== null) {
|
||||
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
|
||||
}
|
||||
return result.consumed;
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
[
|
||||
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"source": "https://github.com/bitwarden/server",
|
||||
"ref": "main",
|
||||
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||
"rulesCount": 91,
|
||||
"domainsCount": 436,
|
||||
"sourceFiles": [
|
||||
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||
"src/Core/Utilities/StaticStore.cs"
|
||||
],
|
||||
"sourceUrls": [
|
||||
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
|
||||
]
|
||||
+195
-7
@@ -2,6 +2,7 @@
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
||||
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
|
||||
ASSETS?: {
|
||||
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
@@ -10,16 +11,20 @@ export interface Env {
|
||||
// Optional fallback for attachment/send file storage (no credit card required).
|
||||
ATTACHMENTS_KV?: KVNamespace;
|
||||
JWT_SECRET: string;
|
||||
TOTP_SECRET?: string;
|
||||
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;
|
||||
@@ -50,11 +55,45 @@ 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;
|
||||
}
|
||||
|
||||
export interface UserDomainSettings {
|
||||
userId: string;
|
||||
equivalentDomains: string[][];
|
||||
customEquivalentDomains: CustomEquivalentDomain[];
|
||||
excludedGlobalEquivalentDomains: number[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CustomEquivalentDomain {
|
||||
id: string;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalEquivalentDomain {
|
||||
type: number;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DomainRulesResponse {
|
||||
equivalentDomains: string[][];
|
||||
customEquivalentDomains: CustomEquivalentDomain[];
|
||||
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||
object: 'domains';
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
code: string;
|
||||
createdBy: string;
|
||||
@@ -68,9 +107,13 @@ export interface Invite {
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
actorUserId: string | null;
|
||||
actorEmail?: string | null;
|
||||
action: string;
|
||||
category: 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||
level: 'info' | 'warn' | 'error' | 'security';
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
targetUserEmail?: string | null;
|
||||
metadata: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -81,6 +124,10 @@ export enum CipherType {
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SSHKey = 5,
|
||||
BankAccount = 6,
|
||||
DriversLicense = 7,
|
||||
Passport = 8,
|
||||
}
|
||||
|
||||
export interface CipherLoginUri {
|
||||
@@ -115,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;
|
||||
@@ -165,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;
|
||||
@@ -196,17 +292,78 @@ export interface Device {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
pushUuid: string | null;
|
||||
pushToken: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
||||
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
purpose: 'login' | 'twoFactor';
|
||||
name: string;
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
counter: number;
|
||||
type: string | null;
|
||||
aaGuid: string | null;
|
||||
transports: string[] | null;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
supportsPrf: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyChallengeScope =
|
||||
| 'Authentication'
|
||||
| 'CreateCredential'
|
||||
| 'UpdateKeySet'
|
||||
| 'TwoFactorAuthentication'
|
||||
| 'TwoFactorCreate';
|
||||
|
||||
export interface AccountPasskeyChallenge {
|
||||
challengeHash: string;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
userId: string | null;
|
||||
expiresAt: number;
|
||||
usedAt: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface DevicePendingAuthRequest {
|
||||
id: string;
|
||||
creationDate: string;
|
||||
}
|
||||
|
||||
export type AuthRequestType = 0 | 1 | 2;
|
||||
|
||||
export interface AuthRequestRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string | null;
|
||||
type: AuthRequestType;
|
||||
requestDeviceIdentifier: string;
|
||||
requestDeviceType: number;
|
||||
requestIpAddress: string | null;
|
||||
requestCountryName: string | null;
|
||||
responseDeviceIdentifier: string | null;
|
||||
accessCode: string;
|
||||
publicKey: string;
|
||||
key: string | null;
|
||||
masterPasswordHash: string | null;
|
||||
approved: boolean | null;
|
||||
creationDate: string;
|
||||
responseDate: string | null;
|
||||
authenticationDate: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceResponse {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
@@ -217,6 +374,7 @@ export interface DeviceResponse {
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastActivityDate?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
@@ -340,6 +498,14 @@ export interface MasterPasswordUnlock {
|
||||
Object: string;
|
||||
}
|
||||
|
||||
export interface WebAuthnPrfDecryptionOption {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
CredentialId: string;
|
||||
Transports: string[];
|
||||
Object?: string;
|
||||
}
|
||||
|
||||
export interface UserDecryptionOptions {
|
||||
HasMasterPassword: boolean;
|
||||
Object: string;
|
||||
@@ -347,6 +513,7 @@ export interface UserDecryptionOptions {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -367,8 +534,18 @@ export interface TokenResponse {
|
||||
ResetMasterPassword: boolean;
|
||||
scope: string;
|
||||
unofficialServer: boolean;
|
||||
UserVerificationToken?: string;
|
||||
userVerificationToken?: string;
|
||||
MasterPasswordPolicy?: {
|
||||
minComplexity: number;
|
||||
minLength: number;
|
||||
requireUpper: boolean;
|
||||
requireLower: boolean;
|
||||
requireNumbers: boolean;
|
||||
requireSpecial: boolean;
|
||||
enforceOnLogin: boolean;
|
||||
Object: string;
|
||||
object?: string;
|
||||
} | null;
|
||||
ApiUseKeyConnector?: boolean;
|
||||
AccountKeys?: any | null;
|
||||
@@ -392,17 +569,19 @@ export interface ProfileResponse {
|
||||
masterPasswordHint: string | null;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
yubikeyEnabled?: boolean;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
accountKeys: any | null;
|
||||
securityStamp: string;
|
||||
organizations: any[];
|
||||
organizationsNew?: any[];
|
||||
providers: any[];
|
||||
providerOrganizations: any[];
|
||||
forcePasswordReset: boolean;
|
||||
avatarColor: string | null;
|
||||
creationDate: string;
|
||||
verifyDevices?: boolean;
|
||||
verifyDevices: boolean;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
object: string;
|
||||
@@ -421,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;
|
||||
@@ -461,12 +643,18 @@ export interface SyncResponse {
|
||||
ciphers: CipherResponse[];
|
||||
domains: any;
|
||||
policies: any[];
|
||||
policiesNew?: any[];
|
||||
sends: SendResponse[];
|
||||
UserDecryption?: {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||
TrustedDeviceOption?: null;
|
||||
KeyConnectorOption?: null;
|
||||
WebAuthnPrfOption?: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
|
||||
V2UpgradeToken?: {
|
||||
WrappedUserKey1: string;
|
||||
WrappedUserKey2: string;
|
||||
} | null;
|
||||
Object?: string;
|
||||
} | null;
|
||||
// PascalCase for desktop/browser clients
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
RegistrationResponseJSON,
|
||||
WebAuthnCredential,
|
||||
} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AccountPasskeyChallengeScope,
|
||||
AccountPasskeyCredential,
|
||||
AccountPasskeyPrfStatus,
|
||||
Env,
|
||||
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;
|
||||
const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000;
|
||||
const DEFAULT_RP_NAME = 'NodeWarden';
|
||||
|
||||
interface AccountPasskeyTokenPayload {
|
||||
typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE;
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
challenge: string;
|
||||
userId: string | null;
|
||||
rpId: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||
const key = await importHmacKey(secret);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return bytesToBase64Url(textBytes(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function decodeJson<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256Base64Url(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', textBytes(value));
|
||||
return bytesToBase64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
|
||||
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
|
||||
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyToken(
|
||||
env: Env,
|
||||
input: {
|
||||
scope: AccountPasskeyChallengeScope;
|
||||
challenge: string;
|
||||
userId?: string | null;
|
||||
rpId: string;
|
||||
ttlMs?: number;
|
||||
}
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const payload: AccountPasskeyTokenPayload = {
|
||||
typ: ACCOUNT_PASSKEY_TOKEN_TYPE,
|
||||
scope: input.scope,
|
||||
challenge: input.challenge,
|
||||
userId: input.userId ?? null,
|
||||
rpId: input.rpId,
|
||||
iat: now,
|
||||
exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)),
|
||||
};
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
|
||||
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyAccountPasskeyToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
scope: AccountPasskeyChallengeScope
|
||||
): Promise<AccountPasskeyTokenPayload | null> {
|
||||
try {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const data = `${parts[0]}.${parts[1]}`;
|
||||
const expected = await hmacSha256(env.JWT_SECRET, data);
|
||||
const actual = base64UrlToBytes(parts[2]);
|
||||
if (actual.length !== expected.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
|
||||
if (diff !== 0) return null;
|
||||
|
||||
const payload = decodeJson<AccountPasskeyTokenPayload>(parts[1]);
|
||||
if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null;
|
||||
if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null;
|
||||
if (payload.exp < Date.now()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } {
|
||||
const url = new URL(request.url);
|
||||
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 = getConfiguredWebAuthnAllowedOrigins(env);
|
||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
||||
return { rpId, rpName, origins: Array.from(origins) };
|
||||
}
|
||||
|
||||
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
||||
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
|
||||
}
|
||||
|
||||
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
||||
if (!userHandle) return null;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function accountPasskeyPrfStatus(credential: Pick<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): AccountPasskeyPrfStatus {
|
||||
if (!credential.supportsPrf) return 2;
|
||||
if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function buildWebAuthnPrfOption(
|
||||
credential: AccountPasskeyCredential
|
||||
): WebAuthnPrfDecryptionOption | null {
|
||||
if (accountPasskeyPrfStatus(credential) !== 0) return null;
|
||||
return {
|
||||
EncryptedPrivateKey: credential.encryptedPrivateKey!,
|
||||
EncryptedUserKey: credential.encryptedUserKey!,
|
||||
CredentialId: credential.credentialId,
|
||||
Transports: credential.transports || [],
|
||||
Object: 'webAuthnPrfDecryptionOption',
|
||||
};
|
||||
}
|
||||
|
||||
export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record<string, unknown> {
|
||||
const prfStatus = accountPasskeyPrfStatus(credential);
|
||||
return {
|
||||
Id: credential.id,
|
||||
id: credential.id,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
PrfStatus: prfStatus,
|
||||
prfStatus,
|
||||
EncryptedPublicKey: credential.encryptedPublicKey,
|
||||
encryptedPublicKey: credential.encryptedPublicKey,
|
||||
EncryptedUserKey: credential.encryptedUserKey,
|
||||
encryptedUserKey: credential.encryptedUserKey,
|
||||
CreationDate: credential.createdAt,
|
||||
RevisionDate: credential.updatedAt,
|
||||
Object: 'webauthnCredential',
|
||||
object: 'webauthnCredential',
|
||||
};
|
||||
}
|
||||
|
||||
export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential {
|
||||
return {
|
||||
id: credential.credentialId,
|
||||
publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)),
|
||||
counter: credential.counter,
|
||||
transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null {
|
||||
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
|
||||
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
|
||||
if (!input || !response) return null;
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
||||
return {
|
||||
id: normalizeWebAuthnBase64(input.id),
|
||||
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
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 ? normalizeWebAuthnBase64(response.publicKey) : undefined,
|
||||
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null {
|
||||
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
|
||||
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
|
||||
if (!input || !response) return null;
|
||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
||||
return {
|
||||
id: normalizeWebAuthnBase64(input.id),
|
||||
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||
type: 'public-key',
|
||||
authenticatorAttachment: input.authenticatorAttachment,
|
||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||
response: {
|
||||
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
|
||||
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||
signature: normalizeWebAuthnBase64(response.signature),
|
||||
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAccountPasskeyName(value: unknown): string {
|
||||
const normalized = String(value || '').trim();
|
||||
return (normalized || 'Account passkey').slice(0, 128);
|
||||
}
|
||||
|
||||
export function normalizeTransports(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const transports = value.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
return transports.length ? transports.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
export function isSerializedEncString(value: unknown): value is string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
const parts = text.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const type = Number(parts[0]);
|
||||
const bodyParts = parts[1].split('|');
|
||||
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||
return false;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
|
||||
'application/xhtml+xml',
|
||||
'application/xml',
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'text/xml',
|
||||
]);
|
||||
|
||||
const SAFE_ICON_MEDIA_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/vnd.microsoft.icon',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
]);
|
||||
|
||||
function normalizeMediaType(contentType: string | null | undefined): string {
|
||||
return String(contentType || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
|
||||
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
|
||||
}
|
||||
|
||||
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
|
||||
const mediaType = normalizeMediaType(contentType);
|
||||
if (!mediaType) return 'application/octet-stream';
|
||||
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
return contentType || mediaType;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { errorResponse } from './response';
|
||||
|
||||
export interface DirectUploadPayload {
|
||||
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
|
||||
fileNameMismatchMessage?: string;
|
||||
}
|
||||
|
||||
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
|
||||
|
||||
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||
const version = '2023-11-03';
|
||||
const expiresAt = '2099-12-31T23:59:59Z';
|
||||
@@ -28,12 +30,16 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
|
||||
|
||||
export function getSafeJwtSecret(env: Env): string | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return null;
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
|
||||
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
|
||||
}
|
||||
|
||||
function parseContentLength(request: Request): number | null {
|
||||
const raw = request.headers.get('content-length');
|
||||
if (!raw) return null;
|
||||
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const declaredSize = parseContentLength(request);
|
||||
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) {
|
||||
return errorResponse(tooLargeMessage, 413);
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
if (!file) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Env } from '../types';
|
||||
|
||||
export function normalizeOrigin(value: unknown): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!url.protocol || !url.host) return null;
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && (
|
||||
normalized.startsWith('chrome-extension://')
|
||||
|| normalized.startsWith('moz-extension://')
|
||||
|| normalized.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredWebAuthnAllowedOrigins(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||
const origin = normalizeOrigin(item);
|
||||
if (origin) seen.add(origin);
|
||||
}
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export function isConfiguredWebAuthnAllowedOrigin(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||
origin: unknown
|
||||
): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
const organizations: any[] = [];
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: true,
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
|
||||
yubikeyEnabled: isYubiKeyEnabled(user),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations,
|
||||
organizationsNew: organizations,
|
||||
providers: [],
|
||||
providerOrganizations: [],
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices !== false,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
object: 'profile',
|
||||
};
|
||||
}
|
||||
+33
-22
@@ -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,42 +24,40 @@ 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 (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
if (!origin) {
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
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)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
|
||||
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
@@ -67,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) {
|
||||
@@ -81,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;
|
||||
@@ -90,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);
|
||||
}
|
||||
@@ -98,7 +103,9 @@ export function applyCors(
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
if (!headers.has('Content-Security-Policy')) {
|
||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@@ -132,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(
|
||||
@@ -148,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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+16
-7
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
export async function findMatchingTotpCounter(
|
||||
secretRaw: string,
|
||||
tokenRaw: string,
|
||||
nowMs: number = Date.now()
|
||||
): Promise<number | null> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
if (!/^\d{6}$/.test(token)) return null;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
if (!secret) return null;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
let matched = false;
|
||||
let matchedCounter: number | null = null;
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
const candidateCounter = currentCounter + delta;
|
||||
const expected = await hotp(secret, candidateCounter);
|
||||
// Constant-time comparison: always check all windows, never short-circuit.
|
||||
const a = new TextEncoder().encode(expected);
|
||||
const b = new TextEncoder().encode(token);
|
||||
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
|
||||
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
if (diff === 0) matched = true;
|
||||
if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
|
||||
}
|
||||
return matched;
|
||||
return matchedCounter;
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User, UserDecryptionOptions } from '../types';
|
||||
import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
|
||||
|
||||
function normalizeOptionalPublicKey(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: user.privateKey,
|
||||
publicKey,
|
||||
signedPublicKey: null,
|
||||
Object: 'publicKeyEncryptionKeyPair',
|
||||
},
|
||||
Object: 'privateKeys',
|
||||
@@ -40,7 +41,8 @@ export function buildMasterPasswordUnlock(
|
||||
}
|
||||
|
||||
export function buildUserDecryptionOptions(
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
|
||||
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
|
||||
): UserDecryptionOptions {
|
||||
return {
|
||||
HasMasterPassword: true,
|
||||
@@ -48,6 +50,7 @@ export function buildUserDecryptionOptions(
|
||||
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
WebAuthnPrfOption: webAuthnPrfOption,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Env } from '../types';
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||
|
||||
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
|
||||
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export type UserVerificationPurpose = 'backup.settings.repair';
|
||||
|
||||
interface UserVerificationTokenPayload {
|
||||
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
|
||||
userId: string;
|
||||
method: 'passkey';
|
||||
purpose: UserVerificationPurpose;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function textBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||
const key = await importHmacKey(secret);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return bytesToBase64Url(textBytes(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function decodeJson<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPasskeyUserVerificationToken(
|
||||
env: Env,
|
||||
userId: string,
|
||||
purpose: UserVerificationPurpose
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const payload: UserVerificationTokenPayload = {
|
||||
typ: USER_VERIFICATION_TOKEN_TYPE,
|
||||
userId,
|
||||
method: 'passkey',
|
||||
purpose,
|
||||
iat: now,
|
||||
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
|
||||
};
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
|
||||
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyPasskeyUserVerificationToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
userId: string,
|
||||
purpose: UserVerificationPurpose
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
const data = `${parts[0]}.${parts[1]}`;
|
||||
const expected = await hmacSha256(env.JWT_SECRET, data);
|
||||
const actual = base64UrlToBytes(parts[2]);
|
||||
if (actual.length !== expected.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
|
||||
if (diff !== 0) return false;
|
||||
|
||||
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
|
||||
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
|
||||
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
|
||||
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -18,6 +18,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 619 B |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user