Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 | ||
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
aae614a079 | ||
|
|
0e46cd371f | ||
|
|
db31792cef | ||
|
|
8c65cb2e80 | ||
|
|
14dff8ee6a | ||
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a | ||
|
|
dd90d7b8b8 | ||
|
|
525b773cf4 | ||
|
|
04cb475935 | ||
|
|
e063f45cd9 | ||
|
|
e10920d142 | ||
|
|
b07edb0850 | ||
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 | ||
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 | ||
|
|
51428461a8 | ||
|
|
23c53bd1af | ||
|
|
ae168bea31 | ||
|
|
00e0ec0892 | ||
|
|
2df43ccdb0 | ||
|
|
fd46dffc34 | ||
|
|
56b301f2d1 | ||
|
|
f0e523376c | ||
|
|
8b2f98b847 | ||
|
|
cde4555add | ||
|
|
1bad32fd90 | ||
|
|
e376a840c2 | ||
|
|
9de0d3bd87 | ||
|
|
109593da90 | ||
|
|
01ff627ac6 | ||
|
|
d028b194e7 | ||
|
|
c53d71fc28 | ||
|
|
6e722205b1 | ||
|
|
cf14704d99 | ||
|
|
0cef6a04e9 | ||
|
|
8c481a1564 | ||
|
|
d9a36fefe6 | ||
|
|
12af18e3a3 | ||
|
|
d8cc88d9c0 | ||
|
|
94b5f3e975 | ||
|
|
062c966e14 | ||
|
|
e73ae3d5ea | ||
|
|
c019c93726 | ||
|
|
f63b745d05 | ||
|
|
c7eb6c663d | ||
|
|
1ec6ed44a1 | ||
|
|
6284c632de | ||
|
|
60dd298dee | ||
|
|
439683d350 | ||
|
|
1545881eae | ||
|
|
680e287c8d | ||
|
|
baf569983d | ||
|
|
73bbe8b268 | ||
|
|
d024798548 | ||
|
|
b0a679b1c2 | ||
|
|
ce3674669e | ||
|
|
aa7b87e041 | ||
|
|
e4215b4025 | ||
|
|
8d292ca7b8 | ||
|
|
5dd9dff045 | ||
|
|
709a8c1768 | ||
|
|
e2c3516ce9 | ||
|
|
55b5c57f9e | ||
|
|
b6fb62603b | ||
|
|
35071c2719 | ||
|
|
5bd7dab277 | ||
|
|
99f2d7f444 | ||
|
|
fb9a2aeda1 | ||
|
|
c87e6ac984 | ||
|
|
a6f1c6dea2 | ||
|
|
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 |
@@ -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://nodewarden.app/sponsor
|
||||
@@ -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,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: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
|
||||
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 the NodeWarden security workflow. 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
|
||||
@@ -19,20 +19,29 @@ jobs:
|
||||
sync-global-domains:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||
env:
|
||||
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
|
||||
run: |
|
||||
case "$BITWARDEN_REF" in
|
||||
"" | *[!A-Za-z0-9._/-]* )
|
||||
echo "Invalid bitwarden_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
npm run domains:sync -- --ref "$BITWARDEN_REF"
|
||||
|
||||
- name: Verify custom domains were not touched
|
||||
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
with:
|
||||
branch: chore/sync-bitwarden-global-domains
|
||||
delete-branch: true
|
||||
|
||||
@@ -1,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
|
||||
@@ -18,6 +18,7 @@ build/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
docs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -42,10 +43,24 @@ 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/
|
||||
|
||||
@@ -3,92 +3,100 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
Bitwarden-compatible server running on Cloudflare Workers
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README_EN.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||
<a href="./README_ZH.md">中文</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||
<a href="https://nodewarden.app">Official wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
> **Disclaimer**
|
||||
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
## Feature comparison with the official Bitwarden server
|
||||
|
||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| 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 NodeWarden 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 Continue with GitHub 并选择你的仓库
|
||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
5. 等部署完成后,打开生成的 Workers 域名
|
||||
1. Fork the NodeWarden repository to your GitHub account
|
||||
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. Choose **Continue with GitHub** and select your fork
|
||||
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||
5. After deployment finishes, open the generated Workers URL
|
||||
|
||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
|
||||
|
||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
|
||||
|
||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> Default R2 vs optional KV:
|
||||
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
## CLI deploy
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
@@ -97,68 +105,46 @@ cd NodeWarden
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
# Default: R2 mode
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
# Optional: KV mode
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
# Local development
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 云端备份说明
|
||||
|
||||
- 远程备份支持 **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
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -1,152 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Bitwarden-compatible server running on Cloudflare Workers
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">中文说明</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
> **Disclaimer**
|
||||
>
|
||||
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||
>
|
||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison with the Official Bitwarden Server
|
||||
|
||||
| Capability | Bitwarden | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||
| 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)
|
||||
@@ -0,0 +1,149 @@
|
||||
<p align="center">
|
||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||
<a href="https://nodewarden.app">官方wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
|
||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 Continue with GitHub 并选择你的仓库
|
||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
5. 等部署完成后,打开生成的 Workers 域名
|
||||
|
||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||
|
||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||
|
||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -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.
|
||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
security_stamp TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||
verify_devices INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT,
|
||||
totp_recovery_code TEXT,
|
||||
api_key TEXT,
|
||||
@@ -176,6 +176,8 @@ 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,
|
||||
@@ -187,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,
|
||||
@@ -198,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,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.5.2",
|
||||
"version": "1.7.4",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
"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",
|
||||
@@ -42,27 +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",
|
||||
"vite": "^7.3.1",
|
||||
"wrangler": "^4.71.0"
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@zip.js/zip.js": "^2.8.22",
|
||||
"fflate": "^0.8.2",
|
||||
"lucide-preact": "^0.575.0",
|
||||
"preact": "^10.28.4",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@zip.js/zip.js": "^2.8.26",
|
||||
"fflate": "^0.8.3",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.9.0"
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ const localeFiles = [
|
||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||
['es', 'es.ts', 'es', 'Spanish'],
|
||||
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||
['de', 'de.ts', 'de', 'German'],
|
||||
['fr', 'fr.ts', 'fr', 'French'],
|
||||
['it', 'it.ts', 'it', 'Italian'],
|
||||
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const scratch = process.env.SCRATCH || '.';
|
||||
const cases = [
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.169.254',
|
||||
'http://[::1]',
|
||||
'http://[0:0:0:0:0:0:0:1]',
|
||||
'http://[::2]',
|
||||
'http://[::]',
|
||||
'http://[fe80::1]',
|
||||
'http://[fc00::1]',
|
||||
'https://example.com',
|
||||
];
|
||||
|
||||
const out = [];
|
||||
for (const url of cases) {
|
||||
try {
|
||||
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
|
||||
out.push({ url, allowed: true, normalized });
|
||||
} catch (e) {
|
||||
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
|
||||
fs.writeFileSync(path, JSON.stringify(out, null, 2));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
|
||||
// Security expectation: IPv6 loopback must NOT be allowed.
|
||||
const loopback = out.find((row) => row.url === 'http://[::1]');
|
||||
if (loopback?.allowed) {
|
||||
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
console.log('IPv6 loopback rejected as expected');
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.5.2';
|
||||
export const APP_VERSION = '1.7.4';
|
||||
|
||||
@@ -9,15 +9,18 @@
|
||||
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;
|
||||
@@ -103,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
// Refresh-token grant budget per IP per minute.
|
||||
// refresh_token 授权每 IP 每分钟请求配额。
|
||||
refreshTokenRequestsPerMinute: 30,
|
||||
// Passwordless/auth-request creation budget per IP/email/device per minute.
|
||||
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
|
||||
authRequestRequestsPerMinute: 5,
|
||||
// Fixed window size for API rate limiting in seconds.
|
||||
// API 限流固定窗口大小(秒)。
|
||||
apiWindowSeconds: 60,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
async function requireMasterPasswordHash(
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
masterPasswordHash: unknown
|
||||
): Promise<Response | null> {
|
||||
const normalized = String(masterPasswordHash || '').trim();
|
||||
if (!normalized) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||
if (!valid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return body && typeof body === 'object' && !Array.isArray(body)
|
||||
? body as Record<string, unknown>
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
@@ -69,18 +97,22 @@ export async function handleAdminListUsers(
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const users = await storage.getAllUsers();
|
||||
return jsonResponse({
|
||||
data: users.map(user => ({
|
||||
const data = await Promise.all(users.map(async user => {
|
||||
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
})),
|
||||
};
|
||||
}));
|
||||
return jsonResponse({
|
||||
data,
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
@@ -183,6 +215,9 @@ export async function handleAdminClearAuditLogs(
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.clearAuditLogs();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||
deleted,
|
||||
}, request);
|
||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||
}
|
||||
|
||||
@@ -197,14 +232,11 @@ export async function handleAdminCreateInvite(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let body: { expiresInHours?: number } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const expiresInHours = Number.isFinite(body.expiresInHours)
|
||||
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
|
||||
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||
: 24 * 7;
|
||||
const now = new Date();
|
||||
@@ -249,7 +281,7 @@ export async function handleAdminListInvites(
|
||||
}
|
||||
|
||||
// DELETE /api/admin/invites/:code
|
||||
export async function handleAdminRevokeInvite(
|
||||
export async function handleAdminDeleteInvite(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
@@ -259,13 +291,19 @@ export async function handleAdminRevokeInvite(
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const 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', null, null, request);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
|
||||
code,
|
||||
}, request);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -275,12 +313,25 @@ export async function handleAdminDeleteAllInvites(
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('scope') === 'invalid') {
|
||||
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,
|
||||
@@ -300,12 +351,9 @@ export async function handleAdminSetUserStatus(
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
let body: { status?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||
if (!nextStatus) {
|
||||
@@ -348,7 +396,6 @@ export async function handleAdminDeleteUser(
|
||||
actorUser: User,
|
||||
targetUserId: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
@@ -356,6 +403,10 @@ export async function handleAdminDeleteUser(
|
||||
return errorResponse('You cannot delete yourself', 400);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||
if (passwordError) return passwordError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const target = await storage.getUserById(targetUserId);
|
||||
if (!target) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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,
|
||||
@@ -31,6 +32,38 @@ 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,
|
||||
@@ -75,6 +108,7 @@ async function runWithConcurrency<T>(
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
attachment: Attachment,
|
||||
cipherId: string
|
||||
): Promise<Response> {
|
||||
@@ -90,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,
|
||||
@@ -116,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 });
|
||||
@@ -132,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);
|
||||
}
|
||||
@@ -170,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) {
|
||||
@@ -208,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(
|
||||
@@ -247,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}
|
||||
@@ -272,13 +312,13 @@ export async function handleGetAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -313,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);
|
||||
}
|
||||
@@ -348,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({
|
||||
@@ -368,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');
|
||||
@@ -381,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);
|
||||
}
|
||||
@@ -400,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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -432,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);
|
||||
}
|
||||
@@ -447,12 +487,13 @@ 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,
|
||||
@@ -461,11 +502,15 @@ export async function handleDeleteAttachment(
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -7,11 +7,20 @@ import {
|
||||
CipherResponse,
|
||||
CipherSecureNote,
|
||||
CipherSshKey,
|
||||
CipherBankAccount,
|
||||
CipherDriversLicense,
|
||||
CipherPassport,
|
||||
Attachment,
|
||||
PasswordHistory,
|
||||
} from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import {
|
||||
notifyUserCipherCreate,
|
||||
notifyUserCipherDelete,
|
||||
notifyUserCipherUpdate,
|
||||
notifyUserCiphersSync,
|
||||
notifyUserVaultSync,
|
||||
} from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||
@@ -26,6 +35,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
||||
// attachments, import/export, and current official clients.
|
||||
export interface CipherResponseOptions {
|
||||
preserveRepairableUris?: boolean;
|
||||
validFolderIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
||||
@@ -42,6 +52,28 @@ function normalizeOptionalId(value: unknown): string | null {
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
|
||||
const normalized = normalizeOptionalId(folderId);
|
||||
if (!normalized) return null;
|
||||
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function buildCipherPermissions(passthrough: Record<string, unknown>): { delete: boolean; restore: boolean } {
|
||||
const raw = passthrough.permissions;
|
||||
const source = raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||
? raw as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
return {
|
||||
delete: readBooleanOrFallback(source?.delete, true),
|
||||
restore: readBooleanOrFallback(source?.restore, true),
|
||||
};
|
||||
}
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -51,6 +83,60 @@ function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
function notifyCipherCreateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserCipherCreate(env, {
|
||||
userId: cipher.userId,
|
||||
cipherId: cipher.id,
|
||||
revisionDate,
|
||||
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||
: null,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
function notifyCipherUpdateForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserCipherUpdate(env, {
|
||||
userId: cipher.userId,
|
||||
cipherId: cipher.id,
|
||||
revisionDate,
|
||||
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||
: null,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
function notifyCipherDeleteForRequest(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipher: Cipher,
|
||||
revisionDate: string
|
||||
): void {
|
||||
notifyUserCipherDelete(env, {
|
||||
userId: cipher.userId,
|
||||
cipherId: cipher.id,
|
||||
revisionDate,
|
||||
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||
: null,
|
||||
contextId: readActingDeviceIdentifier(request),
|
||||
});
|
||||
}
|
||||
|
||||
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||
for (const key of aliases) {
|
||||
@@ -141,6 +227,12 @@ function optionalEncString(value: unknown): string | null {
|
||||
return isValidEncString(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
function optionalEncStringWithin(value: unknown, maxLength: number): string | null {
|
||||
const normalized = optionalEncString(value);
|
||||
if (!normalized) return null;
|
||||
return normalized.length <= maxLength ? normalized : null;
|
||||
}
|
||||
|
||||
function shouldAcceptCipherKey(value: unknown): boolean {
|
||||
return value == null || value === '' || isValidEncString(value);
|
||||
}
|
||||
@@ -151,17 +243,63 @@ function normalizeCipherKeyForStorage(value: unknown): string | null {
|
||||
|
||||
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||
source: T | null | undefined,
|
||||
encryptedKeys: readonly string[]
|
||||
encryptedKeys: readonly string[] | Record<string, number>
|
||||
): T | null {
|
||||
if (!source || typeof source !== 'object') return source ?? null;
|
||||
const next: Record<string, any> = { ...source };
|
||||
for (const key of encryptedKeys) {
|
||||
const entries = Array.isArray(encryptedKeys)
|
||||
? encryptedKeys.map((key) => [key, 10000] as const)
|
||||
: Object.entries(encryptedKeys);
|
||||
for (const [key, maxLength] of entries) {
|
||||
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||
next[key] = optionalEncString(next[key]);
|
||||
next[key] = optionalEncStringWithin(next[key], maxLength);
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
|
||||
'bankName',
|
||||
'nameOnAccount',
|
||||
'accountType',
|
||||
'accountNumber',
|
||||
'routingNumber',
|
||||
'branchNumber',
|
||||
'pin',
|
||||
'swiftCode',
|
||||
'iban',
|
||||
'bankContactPhone',
|
||||
] as const;
|
||||
|
||||
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'dateOfBirth',
|
||||
'licenseNumber',
|
||||
'issuingCountry',
|
||||
'issuingState',
|
||||
'issueDate',
|
||||
'expirationDate',
|
||||
'issuingAuthority',
|
||||
'licenseClass',
|
||||
] as const;
|
||||
|
||||
const PASSPORT_ENCRYPTED_KEYS = [
|
||||
'surname',
|
||||
'givenName',
|
||||
'dateOfBirth',
|
||||
'sex',
|
||||
'birthPlace',
|
||||
'nationality',
|
||||
'issuingCountry',
|
||||
'passportNumber',
|
||||
'passportType',
|
||||
'nationalIdentificationNumber',
|
||||
'issuingAuthority',
|
||||
'issueDate',
|
||||
'expirationDate',
|
||||
] as const;
|
||||
|
||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
@@ -188,7 +326,12 @@ export function normalizeCipherLoginForCompatibility(
|
||||
): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||
const next = sanitizeEncryptedObject(normalized, {
|
||||
username: 1000,
|
||||
password: 5000,
|
||||
totp: 1000,
|
||||
uri: 10000,
|
||||
});
|
||||
if (!next) return null;
|
||||
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
|
||||
requiresUriChecksum,
|
||||
@@ -214,23 +357,19 @@ function normalizeCipherLoginUrisForCompatibility(
|
||||
const hasChecksum = isValidEncString(next.uriChecksum);
|
||||
const hasMatch = next.match != null;
|
||||
|
||||
if (hasUri && hasChecksum) {
|
||||
if (hasUri && String(next.uri).trim().length > 10000) continue;
|
||||
if (hasChecksum && String(next.uriChecksum).trim().length > 10000) {
|
||||
next.uriChecksum = null;
|
||||
}
|
||||
|
||||
if (hasUri && isValidEncString(next.uriChecksum)) {
|
||||
out.push(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasUri && !hasChecksum) {
|
||||
if (options.preserveRepairableUris) {
|
||||
// Preserve the encrypted URI so NodeWarden Web can decrypt it and repair
|
||||
// the missing checksum. Dropping it here makes the URI appear lost and
|
||||
// can turn a display-only compatibility issue into data loss on save.
|
||||
out.push({ ...next, uriChecksum: null });
|
||||
continue;
|
||||
}
|
||||
// Bitwarden browser clients using the SDK drop item-key encrypted URIs
|
||||
// whose checksum is missing/invalid. User-key encrypted legacy/import
|
||||
// entries bypass this validation and can safely keep the URI.
|
||||
if (options.requiresUriChecksum) continue;
|
||||
// Official Bitwarden treats UriChecksum as nullable encrypted metadata.
|
||||
// Keep the URI intact and let clients that can repair checksums do so.
|
||||
out.push({ ...next, uriChecksum: null });
|
||||
continue;
|
||||
}
|
||||
@@ -243,14 +382,69 @@ function normalizeCipherLoginUrisForCompatibility(
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
|
||||
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
|
||||
const uris = (cipher.login as any).uris;
|
||||
if (!Array.isArray(uris)) return false;
|
||||
return uris.some((uri: any) => {
|
||||
if (!uri || typeof uri !== 'object') return false;
|
||||
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum);
|
||||
});
|
||||
export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null {
|
||||
if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.';
|
||||
if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.';
|
||||
|
||||
const login = cipher.login as any;
|
||||
if (login && typeof login === 'object') {
|
||||
if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.';
|
||||
if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.';
|
||||
if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.';
|
||||
if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
|
||||
|
||||
if (Array.isArray(login.uris)) {
|
||||
for (const uri of login.uris) {
|
||||
if (!uri || typeof uri !== 'object') continue;
|
||||
if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
|
||||
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
|
||||
if (Array.isArray(login.fido2Credentials)) {
|
||||
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
for (const cred of login.fido2Credentials) {
|
||||
if (!cred || typeof cred !== 'object') continue;
|
||||
for (const key of fido2EncryptedKeys) {
|
||||
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate SSH key fields — all three must be encrypted strings.
|
||||
const sshKey = cipher.sshKey as any;
|
||||
if (sshKey && typeof sshKey === 'object') {
|
||||
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
|
||||
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
|
||||
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
|
||||
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
|
||||
}
|
||||
|
||||
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
|
||||
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
|
||||
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
|
||||
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
|
||||
];
|
||||
for (const [label, source, keys] of typedEncryptedObjects) {
|
||||
if (!source || typeof source !== 'object') continue;
|
||||
for (const key of keys) {
|
||||
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
|
||||
return `${label} ${key} must be an encrypted string.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password history — each password must be an encrypted string.
|
||||
if (Array.isArray(cipher.passwordHistory)) {
|
||||
for (const entry of cipher.passwordHistory) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||
@@ -589,7 +783,14 @@ export function cipherToResponse(
|
||||
!!responseCipherKey,
|
||||
!!options.preserveRepairableUris
|
||||
);
|
||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, {
|
||||
cardholderName: 1000,
|
||||
brand: 1000,
|
||||
number: 1000,
|
||||
expMonth: 1000,
|
||||
expYear: 1000,
|
||||
code: 1000,
|
||||
});
|
||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||
'title',
|
||||
'firstName',
|
||||
@@ -611,29 +812,40 @@ export function cipherToResponse(
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
const normalizedSecureNote = Number(cipher.type) === 2
|
||||
const normalizedBankAccount = sanitizeEncryptedObject(
|
||||
(passthrough as any).bankAccount ?? null,
|
||||
BANK_ACCOUNT_ENCRYPTED_KEYS
|
||||
);
|
||||
const normalizedDriversLicense = sanitizeEncryptedObject(
|
||||
(passthrough as any).driversLicense ?? null,
|
||||
DRIVERS_LICENSE_ENCRYPTED_KEYS
|
||||
);
|
||||
const normalizedPassport = sanitizeEncryptedObject(
|
||||
(passthrough as any).passport ?? null,
|
||||
PASSPORT_ENCRYPTED_KEYS
|
||||
);
|
||||
const responseType = Number(cipher.type) || 1;
|
||||
const normalizedSecureNote = responseType === 2
|
||||
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||
: null;
|
||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||
const responsePermissions = buildCipherPermissions(passthrough);
|
||||
|
||||
return {
|
||||
// Pass through ALL stored cipher fields (known + unknown)
|
||||
...passthrough,
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeOptionalId(cipher.folderId),
|
||||
type: Number(cipher.type) || 1,
|
||||
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
||||
type: responseType,
|
||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||
creationDate: createdAt,
|
||||
revisionDate: updatedAt,
|
||||
deletedDate: deletedAt,
|
||||
archivedDate: archivedAt ?? null,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
edit: readBooleanOrFallback((passthrough as any).edit, true),
|
||||
viewPassword: readBooleanOrFallback((passthrough as any).viewPassword, true),
|
||||
permissions: responsePermissions,
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(responseAttachments),
|
||||
@@ -646,7 +858,11 @@ export function cipherToResponse(
|
||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||
sshKey: normalizedSshKey,
|
||||
bankAccount: responseType === 6 ? normalizedBankAccount : null,
|
||||
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
|
||||
passport: responseType === 8 ? normalizedPassport : null,
|
||||
key: responseCipherKey,
|
||||
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
};
|
||||
}
|
||||
@@ -680,9 +896,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||
filteredCiphers.map((cipher) => cipher.id)
|
||||
);
|
||||
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
@@ -699,7 +916,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
// GET /api/ciphers/:id
|
||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -714,8 +931,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
|
||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||
if (!folderId) return true;
|
||||
const folder = await storage.getFolder(folderId);
|
||||
return !!(folder && folder.userId === userId);
|
||||
const folder = await storage.getFolderForUser(folderId, userId);
|
||||
return !!folder;
|
||||
}
|
||||
|
||||
// POST /api/ciphers
|
||||
@@ -739,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
|
||||
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
|
||||
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
|
||||
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
||||
@@ -768,10 +988,15 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
|
||||
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
|
||||
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
|
||||
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
normalizeCipherForStorage(cipher);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||
|
||||
// Prevent referencing a folder owned by another user.
|
||||
if (cipher.folderId) {
|
||||
@@ -779,13 +1004,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
if (hasMissingLoginUriChecksum(cipher)) {
|
||||
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
||||
}
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherCreateForRequest(request, env, cipher, revisionDate);
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
|
||||
return jsonResponse(
|
||||
@@ -797,7 +1019,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
// PUT /api/ciphers/:id
|
||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const existingCipher = await storage.getCipher(id);
|
||||
const existingCipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!existingCipher || existingCipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -820,9 +1042,15 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
|
||||
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
|
||||
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
const preserveRevisionDate =
|
||||
shouldPreserveRepairableCipherUris(request)
|
||||
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
|
||||
|
||||
if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
|
||||
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||
@@ -832,17 +1060,14 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) {
|
||||
return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400);
|
||||
}
|
||||
|
||||
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||
const cipher: Cipher = {
|
||||
...existingCipher, // start with all existing stored data (including unknowns)
|
||||
...cipherData, // overlay all client data (including new/unknown fields)
|
||||
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
|
||||
// Server-controlled fields (never from client)
|
||||
id: existingCipher.id,
|
||||
userId: existingCipher.userId,
|
||||
@@ -850,7 +1075,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
@@ -868,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
|
||||
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
|
||||
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
|
||||
if (incomingPasswordHistory.present) {
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
@@ -883,6 +1111,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.fields = null;
|
||||
}
|
||||
normalizeCipherForStorage(cipher);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||
|
||||
// Prevent referencing a folder owned by another user.
|
||||
if (cipher.folderId) {
|
||||
@@ -890,14 +1120,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
if (hasMissingLoginUriChecksum(cipher)) {
|
||||
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
||||
}
|
||||
|
||||
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
|
||||
@@ -909,7 +1136,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// DELETE /api/ciphers/:id
|
||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -922,6 +1149,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
|
||||
id: cipher.id,
|
||||
type: cipher.type,
|
||||
@@ -940,7 +1168,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
// - If item is already soft-deleted -> hard delete.
|
||||
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -951,6 +1179,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||
id,
|
||||
type: cipher.type,
|
||||
@@ -966,7 +1195,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
// DELETE /api/ciphers/:id (permanent)
|
||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -978,6 +1207,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||
id,
|
||||
type: cipher.type,
|
||||
@@ -990,7 +1220,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
// PUT /api/ciphers/:id/restore
|
||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1002,6 +1232,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||
@@ -1011,7 +1242,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1041,6 +1272,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||
@@ -1102,7 +1334,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
||||
// PUT/POST /api/ciphers/:id/archive
|
||||
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1117,6 +1349,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
@@ -1127,7 +1360,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
// PUT/POST /api/ciphers/:id/unarchive
|
||||
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1165,6 +1398,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
|
||||
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
@@ -1189,6 +1423,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
|
||||
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
return buildCipherListResponse(request, storage, userId, ids);
|
||||
@@ -1212,6 +1447,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
|
||||
count: body.ids.length,
|
||||
});
|
||||
@@ -1238,6 +1474,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
||||
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -1274,6 +1511,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
||||
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
|
||||
count: ownedIds.length,
|
||||
requestedCount: ids.length,
|
||||
|
||||
@@ -3,9 +3,10 @@ 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);
|
||||
@@ -47,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,
|
||||
@@ -122,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
|
||||
@@ -223,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,
|
||||
@@ -325,10 +409,12 @@ 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);
|
||||
}
|
||||
@@ -378,11 +464,26 @@ export async function handleUpdateDeviceName(
|
||||
|
||||
// DELETE /api/devices
|
||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let masterPasswordHash = '';
|
||||
try {
|
||||
const body = await request.json() as { masterPasswordHash?: string };
|
||||
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
|
||||
} catch {
|
||||
masterPasswordHash = '';
|
||||
}
|
||||
if (!masterPasswordHash) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||
if (!passwordValid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
|
||||
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||
storage.deleteRefreshTokensByUserId(userId),
|
||||
@@ -537,10 +638,12 @@ 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);
|
||||
}
|
||||
@@ -557,18 +660,36 @@ export async function handleDeactivateDevice(
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -594,9 +715,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,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);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
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';
|
||||
@@ -75,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);
|
||||
@@ -111,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);
|
||||
}
|
||||
@@ -118,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);
|
||||
@@ -139,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));
|
||||
}
|
||||
@@ -146,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);
|
||||
@@ -156,6 +173,12 @@ 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,
|
||||
});
|
||||
@@ -179,9 +202,23 @@ 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,
|
||||
});
|
||||
|
||||
@@ -1,29 +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 {
|
||||
@@ -44,6 +60,44 @@ async function resolveDeviceSession(
|
||||
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';
|
||||
}
|
||||
@@ -60,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 {
|
||||
@@ -104,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,
|
||||
@@ -126,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.
|
||||
@@ -224,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;
|
||||
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);
|
||||
@@ -268,11 +394,34 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
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: 'auth.login.failed.bad_password',
|
||||
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
@@ -290,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());
|
||||
@@ -304,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;
|
||||
@@ -319,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.
|
||||
@@ -367,10 +559,14 @@ 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);
|
||||
@@ -398,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,
|
||||
@@ -408,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,
|
||||
};
|
||||
@@ -430,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const scope = body.scope;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
const loginIdentifier = clientIdentifier;
|
||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||
if (!parmValid) {
|
||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||
}
|
||||
const uid = clientId.slice(5);
|
||||
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
@@ -446,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);
|
||||
@@ -470,7 +786,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
@@ -498,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
|
||||
@@ -538,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,
|
||||
@@ -590,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;
|
||||
@@ -678,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,
|
||||
@@ -731,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') || '';
|
||||
|
||||
@@ -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,6 +17,9 @@ interface CiphersImportRequest {
|
||||
favorite?: boolean;
|
||||
reprompt?: number;
|
||||
sshKey?: any | null;
|
||||
bankAccount?: any | null;
|
||||
driversLicense?: any | null;
|
||||
passport?: any | null;
|
||||
key?: string | null;
|
||||
login?: {
|
||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||
const chunk = statements.slice(i, i + chunkSize);
|
||||
@@ -112,9 +121,9 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const folders = importData.folders || [];
|
||||
const ciphers = importData.ciphers || [];
|
||||
const folderRelationships = importData.folderRelationships || [];
|
||||
const folders = Array.isArray(importData.folders) ? importData.folders : [];
|
||||
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
|
||||
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
|
||||
|
||||
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
||||
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
||||
@@ -128,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const folderRows: Folder[] = [];
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
|
||||
const folderId = generateUUID();
|
||||
folderIdMap.set(i, folderId);
|
||||
|
||||
const folder: Folder = {
|
||||
id: folderId,
|
||||
userId: userId,
|
||||
name: folders[i].name,
|
||||
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -157,24 +167,31 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
// Build cipher index -> folder id mapping from relationships
|
||||
const cipherFolderMap = new Map<number, string>();
|
||||
for (const rel of folderRelationships) {
|
||||
if (!rel || typeof rel !== 'object') continue;
|
||||
const folderId = folderIdMap.get(rel.value);
|
||||
if (folderId) {
|
||||
cipherFolderMap.set(rel.key, folderId);
|
||||
}
|
||||
}
|
||||
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||
|
||||
// Create ciphers
|
||||
const cipherRows: Cipher[] = [];
|
||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
|
||||
const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
|
||||
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : null);
|
||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||
const sourceId = sourceIdRaw || null;
|
||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
|
||||
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
|
||||
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
|
||||
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
|
||||
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
})) || null,
|
||||
passwordHistory: passwordHistory ?? null,
|
||||
reprompt: c.reprompt ?? 0,
|
||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||
sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
|
||||
bankAccount: bankAccount ?? null,
|
||||
driversLicense: driversLicense ?? null,
|
||||
passport: passport ?? null,
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -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,
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
} from './sends-shared';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
|
||||
|
||||
async function writeSendAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
@@ -79,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: {
|
||||
@@ -99,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 });
|
||||
}
|
||||
@@ -130,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);
|
||||
@@ -206,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 = {
|
||||
@@ -249,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));
|
||||
}
|
||||
@@ -329,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 = {
|
||||
@@ -372,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);
|
||||
@@ -395,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);
|
||||
}
|
||||
@@ -430,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);
|
||||
}
|
||||
@@ -466,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);
|
||||
}
|
||||
@@ -479,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);
|
||||
}
|
||||
@@ -586,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']);
|
||||
@@ -598,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;
|
||||
}
|
||||
}
|
||||
@@ -619,13 +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> {
|
||||
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);
|
||||
}
|
||||
@@ -641,6 +672,7 @@ 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,
|
||||
@@ -676,6 +708,9 @@ 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,
|
||||
@@ -687,7 +722,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -697,6 +732,7 @@ 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,
|
||||
@@ -707,7 +743,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -718,6 +754,7 @@ 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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -105,7 +155,15 @@ export function formatSize(bytes: number): string {
|
||||
|
||||
export function parseDate(raw: unknown): Date | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
const date = new Date(raw);
|
||||
let value = raw.trim();
|
||||
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
||||
value += 'Z';
|
||||
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
|
||||
value = value.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
@@ -321,7 +379,7 @@ export function hasEmailAuth(send: Send): boolean {
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
@@ -384,8 +442,8 @@ export type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||
}
|
||||
|
||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||
@@ -414,7 +472,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
|
||||
|
||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||
if (hasEmailAuth(send)) {
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
return {
|
||||
ok: false,
|
||||
response: errorResponse('Send email verification is not supported by this server.', 501),
|
||||
reason: 'email_auth_unsupported',
|
||||
};
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
@@ -5,11 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira
|
||||
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';
|
||||
|
||||
// CONTRACT:
|
||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||
@@ -20,13 +21,14 @@ 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'}/${preserveRepairableUris ? '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' });
|
||||
@@ -57,8 +59,19 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends, preserveRepairableUris);
|
||||
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;
|
||||
@@ -71,37 +84,17 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
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) || [], { preserveRepairableUris });
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
@@ -133,11 +126,15 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
{ 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,
|
||||
|
||||
@@ -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';
|
||||
@@ -23,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith('/identity/') ||
|
||||
path.startsWith('/icons/') ||
|
||||
path.startsWith('/fill-assist/') ||
|
||||
path.startsWith('/notifications/') ||
|
||||
path.startsWith('/.well-known/') ||
|
||||
path === '/v1/assetlinks:check' ||
|
||||
path === '/web-bootstrap' ||
|
||||
path === '/config' ||
|
||||
path === '/api/config' ||
|
||||
path === '/api/version'
|
||||
@@ -88,7 +92,7 @@ export default {
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
return applyCors(normalizedRequest, assetResponse, env);
|
||||
}
|
||||
|
||||
await ensureDatabaseInitialized(env);
|
||||
@@ -106,11 +110,11 @@ export default {
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
}
|
||||
|
||||
const resp = await handleRequest(normalizedRequest, env);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
return applyCors(normalizedRequest, resp, env);
|
||||
},
|
||||
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
@@ -127,3 +131,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
handleAdminCreateInvite,
|
||||
handleAdminListInvites,
|
||||
handleAdminDeleteAllInvites,
|
||||
handleAdminRevokeInvite,
|
||||
handleAdminDeleteInvite,
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
handleAdminListAuditLogs,
|
||||
@@ -13,6 +13,23 @@ import {
|
||||
handleAdminClearAuditLogs,
|
||||
} from './handlers/admin';
|
||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
function isKnownAdminPath(path: string): boolean {
|
||||
return (
|
||||
path === '/api/admin/users' ||
|
||||
path === '/api/admin/logs' ||
|
||||
path === '/api/admin/logs/settings' ||
|
||||
path === '/api/admin/invites' ||
|
||||
path.startsWith('/api/admin/backup') ||
|
||||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
|
||||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
export async function handleAdminRoute(
|
||||
request: Request,
|
||||
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (!isKnownAdminPath(path)) {
|
||||
return null;
|
||||
}
|
||||
if (!isActiveAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, actorUser);
|
||||
}
|
||||
@@ -52,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);
|
||||
|
||||
@@ -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';
|
||||
@@ -66,6 +77,25 @@ import {
|
||||
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,
|
||||
@@ -87,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);
|
||||
@@ -97,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') {
|
||||
@@ -111,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);
|
||||
}
|
||||
@@ -131,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);
|
||||
}
|
||||
@@ -232,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/')) {
|
||||
|
||||
@@ -18,8 +18,14 @@ import {
|
||||
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,
|
||||
@@ -27,31 +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 permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/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(/^\/api\/devices\/([^/]+)$/i);
|
||||
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||
if (deleteDeviceMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||
@@ -61,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
@@ -8,31 +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 {
|
||||
@@ -90,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist/`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
@@ -102,7 +121,7 @@ function buildConfigResponse(origin: string) {
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: null,
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
@@ -118,8 +137,11 @@ function buildConfigResponse(origin: string) {
|
||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'fill-assist-targeting-rules': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
'pm-19148-innovation-archive': true,
|
||||
'pm-4516-devices-add-last-activity-date': true,
|
||||
'pm-30529-webauthn-related-origins': true,
|
||||
'unauth-ui-refresh': true,
|
||||
'web-push': false,
|
||||
},
|
||||
@@ -235,11 +257,16 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
||||
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(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
if (!isWebsiteIconProxyEnabled(env)) {
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
|
||||
const normalizedHost = normalizeIconHost(host);
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
@@ -266,7 +293,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!contentType.startsWith('image/')) continue;
|
||||
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||
|
||||
const contentLength = getPositiveContentLength(resp.headers);
|
||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||
@@ -295,9 +322,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -308,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
jwtUnsafeReason,
|
||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||
registrationInviteRequired: userCount > 0,
|
||||
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,12 +361,31 @@ export async function handlePublicRoute(
|
||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||
}
|
||||
|
||||
if (path === '/fill-assist/manifest.json' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleFillAssistManifest();
|
||||
}
|
||||
|
||||
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleDigitalAssetLinkCheck();
|
||||
}
|
||||
|
||||
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||
if (fillAssistFormsMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleFillAssistForms(fillAssistFormsMatch[1]);
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
@@ -389,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);
|
||||
}
|
||||
@@ -422,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;
|
||||
@@ -442,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') {
|
||||
@@ -470,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
|
||||
'skippedReason',
|
||||
'replaceExisting',
|
||||
'provider',
|
||||
'prfStatus',
|
||||
'fileName',
|
||||
'fileBytes',
|
||||
'bytes',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zipSync, unzipSync } from 'fflate';
|
||||
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import { APP_VERSION } from '../../shared/app-version';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
||||
@@ -28,10 +28,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||
const BACKUP_JSON_INDENT = 2;
|
||||
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
|
||||
|
||||
export interface BackupManifest {
|
||||
formatVersion: 1;
|
||||
@@ -67,6 +68,8 @@ export interface BackupPayload {
|
||||
folders: SqlRow[];
|
||||
ciphers: SqlRow[];
|
||||
attachments: SqlRow[];
|
||||
webauthn_credentials?: SqlRow[];
|
||||
trusted_two_factor_device_tokens?: SqlRow[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,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) {
|
||||
@@ -221,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');
|
||||
}
|
||||
|
||||
@@ -233,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) {
|
||||
@@ -300,6 +362,8 @@ export function validateBackupPayloadContents(
|
||||
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`)
|
||||
@@ -364,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`;
|
||||
@@ -372,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(
|
||||
@@ -390,14 +486,16 @@ export async function buildBackupArchive(
|
||||
includeAttachments,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, domainSettingsRows, 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, 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 : [];
|
||||
@@ -425,6 +523,8 @@ export async function buildBackupArchive(
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: exportedAttachmentRows.length,
|
||||
webauthn_credentials: accountPasskeyRows.length,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: includeAttachments,
|
||||
@@ -447,6 +547,8 @@ export async function buildBackupArchive(
|
||||
folders: folderRows,
|
||||
ciphers: cipherRows,
|
||||
attachments: exportedAttachmentRows,
|
||||
webauthn_credentials: accountPasskeyRows,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,163 @@ function normalizePath(value: unknown): string {
|
||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeHostnameForPolicy(hostname: string): string {
|
||||
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
function parseIpv4Address(hostname: string): number[] | null {
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
const octets = parts.map((part) => {
|
||||
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||
const value = Number(part);
|
||||
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||
});
|
||||
return octets.every((value) => value >= 0) ? octets : null;
|
||||
}
|
||||
|
||||
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||
const [a, b, c] = octets;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && (b === 0 || b === 168)) ||
|
||||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
a >= 224
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
|
||||
* Needed so compressed forms like "::1" are not misclassified by a naive
|
||||
* "first non-empty hextet" check (which would read "1" and miss loopback).
|
||||
*/
|
||||
function expandIpv6Address(hostname: string): string[] | null {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!normalized.includes(':')) return null;
|
||||
if (normalized.includes('.')) {
|
||||
// IPv4-embedded forms are handled separately by the caller.
|
||||
return null;
|
||||
}
|
||||
if ((normalized.match(/::/g) || []).length > 1) return null;
|
||||
|
||||
const sides = normalized.split('::');
|
||||
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
|
||||
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
|
||||
if (left.length + right.length > 8) return null;
|
||||
if (sides.length === 1 && left.length !== 8) return null;
|
||||
|
||||
const missing = 8 - left.length - right.length;
|
||||
if (sides.length > 1 && missing < 0) return null;
|
||||
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
|
||||
const parts = [...left, ...middle, ...right];
|
||||
if (parts.length !== 8) return null;
|
||||
|
||||
const hextets: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
||||
hextets.push(part.padStart(4, '0'));
|
||||
}
|
||||
return hextets;
|
||||
}
|
||||
|
||||
function isBlockedIpv6Address(hostname: string): boolean {
|
||||
if (!hostname.includes(':')) return false;
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
|
||||
// IPv4-mapped dotted form: ::ffff:127.0.0.1
|
||||
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
||||
if (mappedIpv4) {
|
||||
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||
return !octets || isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
|
||||
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||
if (mappedHex) {
|
||||
const hi = Number.parseInt(mappedHex[1], 16);
|
||||
const lo = Number.parseInt(mappedHex[2], 16);
|
||||
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
|
||||
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
||||
return isBlockedIpv4Address(octets);
|
||||
}
|
||||
|
||||
const hextets = expandIpv6Address(normalized);
|
||||
if (!hextets) return true;
|
||||
const firstHextet = Number.parseInt(hextets[0], 16);
|
||||
if (!Number.isFinite(firstHextet)) return true;
|
||||
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
|
||||
return (
|
||||
firstHextet === 0 ||
|
||||
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||
(firstHextet & 0xff00) === 0xff00 ||
|
||||
hextets.join(':').startsWith('2001:0db8:')
|
||||
);
|
||||
}
|
||||
|
||||
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||
const normalized = normalizeHostnameForPolicy(hostname);
|
||||
if (!normalized) throw new Error(`${label} host is required`);
|
||||
if (
|
||||
normalized === 'localhost' ||
|
||||
normalized === 'localhost.localdomain' ||
|
||||
normalized.endsWith('.localhost.localdomain') ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized.endsWith('.home.arpa') ||
|
||||
normalized.endsWith('.internal') ||
|
||||
normalized.endsWith('.lan') ||
|
||||
normalized === 'metadata.google.internal' ||
|
||||
normalized === 'localtest.me' ||
|
||||
normalized.endsWith('.localtest.me') ||
|
||||
normalized === 'lvh.me' ||
|
||||
normalized.endsWith('.lvh.me') ||
|
||||
normalized === 'vcap.me' ||
|
||||
normalized.endsWith('.vcap.me') ||
|
||||
normalized === 'nip.io' ||
|
||||
normalized.endsWith('.nip.io') ||
|
||||
normalized === 'sslip.io' ||
|
||||
normalized.endsWith('.sslip.io') ||
|
||||
normalized === 'xip.io' ||
|
||||
normalized.endsWith('.xip.io')
|
||||
) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
const ipv4 = parseIpv4Address(normalized);
|
||||
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
if (isBlockedIpv6Address(normalized)) {
|
||||
throw new Error(`${label} host is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must start with http:// or https://`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error(`${label} must not include credentials`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${label} must not include query or fragment`);
|
||||
}
|
||||
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function assertValidTimeZone(timezone: string): string {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||
@@ -109,6 +270,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 +280,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
||||
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('S3 bucket is required');
|
||||
@@ -129,8 +293,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 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
|
||||
if (!allowIncomplete || baseUrl) {
|
||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||
}
|
||||
if (!allowIncomplete || username) {
|
||||
if (!username) throw new Error('WebDAV username is required');
|
||||
@@ -157,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||
username,
|
||||
password,
|
||||
remotePath,
|
||||
@@ -173,6 +338,32 @@ function normalizeDestination(
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
function shouldPreserveBackupSecret(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
const raw = String(value);
|
||||
return raw === '' || raw === REDACTED_BACKUP_SECRET;
|
||||
}
|
||||
|
||||
function withPreservedDestinationSecret(
|
||||
destinationType: BackupDestinationType,
|
||||
inputDestination: unknown,
|
||||
previous: BackupDestinationRecord | undefined
|
||||
): unknown {
|
||||
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
|
||||
if (destinationType === 's3') {
|
||||
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
|
||||
source.secretAccessKey = previousDestination?.secretAccessKey || '';
|
||||
}
|
||||
} else {
|
||||
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
|
||||
if (shouldPreserveBackupSecret(source.password)) {
|
||||
source.password = previousDestination?.password || '';
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const asIso = (input: unknown): string | null => {
|
||||
@@ -243,7 +434,11 @@ function normalizeDestinationRecord(
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
|
||||
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||
const destination = normalizeDestination(
|
||||
type,
|
||||
withPreservedDestinationSecret(type, input.destination, previous),
|
||||
!schedule.enabled
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -318,6 +513,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 +617,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');
|
||||
}
|
||||
@@ -411,6 +675,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
||||
const users = await storage.getAllUsers();
|
||||
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> {
|
||||
@@ -590,9 +875,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);
|
||||
@@ -614,7 +899,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;
|
||||
}
|
||||
}
|
||||
@@ -631,9 +916,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(
|
||||
@@ -646,7 +931,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,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
isSafeBackupAttachmentBlobName,
|
||||
parseBackupArchive,
|
||||
validateBackupPayloadContents,
|
||||
} from './backup-archive';
|
||||
@@ -24,6 +25,8 @@ type BackupTableName =
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'trusted_two_factor_device_tokens'
|
||||
| 'webauthn_credentials'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
@@ -33,6 +36,8 @@ const BACKUP_TABLES: BackupTableName[] = [
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'trusted_two_factor_device_tokens',
|
||||
'webauthn_credentials',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
@@ -49,6 +54,8 @@ export interface BackupImportResultBody {
|
||||
users: number;
|
||||
domainSettings: number;
|
||||
userRevisions: number;
|
||||
trustedTwoFactorDeviceTokens: number;
|
||||
webauthnCredentials: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
@@ -168,6 +175,8 @@ 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',
|
||||
@@ -245,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) => {
|
||||
@@ -288,10 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
verify_devices: row.verify_devices ?? 0,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||
...row,
|
||||
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||
})),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
@@ -451,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(
|
||||
@@ -609,7 +639,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
@@ -629,6 +659,26 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
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'),
|
||||
@@ -697,6 +747,8 @@ export async function importBackupArchiveBytes(
|
||||
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,
|
||||
@@ -719,6 +771,8 @@ export async function importBackupArchiveBytes(
|
||||
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,
|
||||
@@ -759,6 +813,8 @@ export async function importBackupArchiveBytes(
|
||||
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,
|
||||
@@ -835,6 +891,8 @@ export async function importRemoteBackupArchiveBytes(
|
||||
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,
|
||||
@@ -857,6 +915,8 @@ export async function importRemoteBackupArchiveBytes(
|
||||
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,
|
||||
@@ -903,6 +963,8 @@ export async function importRemoteBackupArchiveBytes(
|
||||
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,
|
||||
|
||||
@@ -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,61 +558,68 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
let continuationToken = '';
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
if (!fullPrefix) continue;
|
||||
const relative = rootPrefix
|
||||
? fullPrefix === rootPrefix
|
||||
? ''
|
||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||
? fullPrefix.slice(rootPrefix.length + 1)
|
||||
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) {
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
if (!fullPrefix) continue;
|
||||
const relative = rootPrefix
|
||||
? fullPrefix === rootPrefix
|
||||
? ''
|
||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||
? fullPrefix.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullPrefix;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative) continue;
|
||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: itemPath,
|
||||
name: basename(itemPath) || itemPath,
|
||||
isDirectory: true,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||
const relative = rootPrefix
|
||||
? fullKey.startsWith(`${rootPrefix}/`)
|
||||
? fullKey.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullPrefix;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative) continue;
|
||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: itemPath,
|
||||
name: basename(itemPath) || itemPath,
|
||||
isDirectory: true,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
: fullKey;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: normalizedRelative,
|
||||
name: basename(normalizedRelative) || normalizedRelative,
|
||||
isDirectory: false,
|
||||
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||
const relative = rootPrefix
|
||||
? fullKey.startsWith(`${rootPrefix}/`)
|
||||
? fullKey.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullKey;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: normalizedRelative,
|
||||
name: basename(normalizedRelative) || normalizedRelative,
|
||||
isDirectory: false,
|
||||
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||
});
|
||||
}
|
||||
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,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 };
|
||||
}
|
||||
@@ -117,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -87,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);
|
||||
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
|
||||
.bind(id, userId)
|
||||
.first<CipherRow>();
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = buildCipherData(cipher, folderId);
|
||||
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||
'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -244,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
|
||||
@@ -341,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 = ?')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
@@ -78,6 +84,7 @@ 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)',
|
||||
|
||||
@@ -94,7 +101,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'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)',
|
||||
@@ -103,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 {
|
||||
@@ -23,9 +23,15 @@ function mapUserRow(row: any): User {
|
||||
securityStamp: row.security_stamp,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } 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,
|
||||
@@ -21,7 +22,10 @@ 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,
|
||||
@@ -29,7 +33,7 @@ import {
|
||||
markInviteUsed as markStoredInviteUsed,
|
||||
pruneAuditLogs as pruneStoredAuditLogs,
|
||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||
revokeInvite as revokeStoredInvite,
|
||||
revertInviteUsed as revertStoredInviteUsed,
|
||||
} from './storage-admin-repo';
|
||||
import {
|
||||
bulkDeleteFolders as deleteStoredFolders,
|
||||
@@ -37,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';
|
||||
@@ -49,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,
|
||||
@@ -56,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,
|
||||
@@ -71,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,
|
||||
@@ -87,26 +97,44 @@ 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,
|
||||
@@ -115,6 +143,18 @@ 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';
|
||||
@@ -122,7 +162,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
|
||||
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:
|
||||
@@ -135,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) {}
|
||||
@@ -153,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,
|
||||
@@ -196,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;
|
||||
}
|
||||
@@ -272,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> {
|
||||
@@ -323,12 +393,93 @@ export class StorageService {
|
||||
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);
|
||||
}
|
||||
@@ -379,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);
|
||||
}
|
||||
@@ -417,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);
|
||||
}
|
||||
@@ -425,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);
|
||||
}
|
||||
@@ -445,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);
|
||||
}
|
||||
@@ -505,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);
|
||||
}
|
||||
@@ -591,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,
|
||||
@@ -611,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);
|
||||
}
|
||||
@@ -623,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);
|
||||
}
|
||||
@@ -655,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> {
|
||||
|
||||
@@ -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,15 +11,20 @@ export interface Env {
|
||||
// Optional fallback for attachment/send file storage (no credit card required).
|
||||
ATTACHMENTS_KV?: KVNamespace;
|
||||
JWT_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;
|
||||
@@ -49,6 +55,12 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
yubikeyKey1: string | null;
|
||||
yubikeyKey2: string | null;
|
||||
yubikeyKey3: string | null;
|
||||
yubikeyKey4: string | null;
|
||||
yubikeyKey5: string | null;
|
||||
yubikeyNfc: boolean;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -112,6 +124,10 @@ export enum CipherType {
|
||||
SecureNote = 2,
|
||||
Card = 3,
|
||||
Identity = 4,
|
||||
SSHKey = 5,
|
||||
BankAccount = 6,
|
||||
DriversLicense = 7,
|
||||
Passport = 8,
|
||||
}
|
||||
|
||||
export interface CipherLoginUri {
|
||||
@@ -146,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;
|
||||
@@ -196,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;
|
||||
@@ -227,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;
|
||||
@@ -248,6 +374,7 @@ export interface DeviceResponse {
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastActivityDate?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
@@ -371,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;
|
||||
@@ -378,6 +513,7 @@ export interface UserDecryptionOptions {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -398,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;
|
||||
@@ -423,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;
|
||||
@@ -452,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;
|
||||
@@ -492,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,50 @@
|
||||
import type { Env } from '../types';
|
||||
|
||||
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
|
||||
// These are the stable store IDs for the official Chromium-based extensions.
|
||||
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
|
||||
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
|
||||
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||
] as const;
|
||||
|
||||
export function normalizeOrigin(value: unknown): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (!url.protocol || !url.host) return null;
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && (
|
||||
normalized.startsWith('chrome-extension://')
|
||||
|| normalized.startsWith('moz-extension://')
|
||||
|| normalized.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredWebAuthnAllowedOrigins(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||
): string[] {
|
||||
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
|
||||
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||
const origin = normalizeOrigin(item);
|
||||
if (origin) seen.add(origin);
|
||||
}
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export function isConfiguredWebAuthnAllowedOrigin(
|
||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||
origin: unknown
|
||||
): boolean {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
// New-device verification is not supported without an email delivery channel.
|
||||
// Always report disabled so clients do not present a false security posture.
|
||||
verifyDevices: false,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
object: 'profile',
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
isBrowserExtensionOrigin,
|
||||
isConfiguredWebAuthnAllowedOrigin,
|
||||
normalizeOrigin,
|
||||
} from './origins';
|
||||
|
||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||
const DEFAULT_CORS_HEADERS = [
|
||||
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
|
||||
'X-NodeWarden-Web-Session',
|
||||
];
|
||||
|
||||
function isExtensionOrigin(origin: string): boolean {
|
||||
return (
|
||||
origin.startsWith('chrome-extension://')
|
||||
|| origin.startsWith('moz-extension://')
|
||||
|| origin.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
function isWildcardCorsPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/icons/')
|
||||
|| path.startsWith('/fill-assist/')
|
||||
|| path === '/v1/assetlinks:check'
|
||||
|| path === '/api/v1/assetlinks:check'
|
||||
|| path === '/config'
|
||||
|| path === '/api/config'
|
||||
|| path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) {
|
||||
const originHeader = request.headers.get('Origin');
|
||||
if (!originHeader) {
|
||||
return isWildcardCorsPath(url.pathname)
|
||||
? { allowOrigin: '*', allowCredentials: false }
|
||||
: { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
const origin = normalizeOrigin(originHeader);
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
|
||||
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||
};
|
||||
|
||||
const corsPolicy = getCorsPolicy(request);
|
||||
const corsPolicy = getCorsPolicy(request, env);
|
||||
if (corsPolicy.allowOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||
if (corsPolicy.allowCredentials) {
|
||||
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
|
||||
export function applyCors(
|
||||
request: Request,
|
||||
response: Response
|
||||
response: Response,
|
||||
env: Env
|
||||
): Response {
|
||||
// WebSocket upgrade responses must be returned untouched.
|
||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||
@@ -92,7 +95,7 @@ export function applyCors(
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
const corsHeaders = buildCorsHeaders(request);
|
||||
const corsHeaders = buildCorsHeaders(request, env);
|
||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
@@ -100,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');
|
||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||
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,
|
||||
@@ -134,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(
|
||||
@@ -150,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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
connect-src 'self' https://api.pwnedpasswords.com;
|
||||
font-src 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Full-bleed background for any/maskable -->
|
||||
<rect width="512" height="512" fill="#116FF9"/>
|
||||
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
|
||||
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
|
||||
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "NodeWarden",
|
||||
"short_name": "NodeWarden",
|
||||
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
|
||||
"id": "/",
|
||||
"start_url": "/vault",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#eef4ff",
|
||||
"theme_color": "#0f172a",
|
||||
"categories": ["security", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Vault",
|
||||
"short_name": "Vault",
|
||||
"url": "/vault",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "TOTP Codes",
|
||||
"short_name": "TOTP",
|
||||
"url": "/vault/totp",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--line: #d8e0ec;
|
||||
--panel: #ffffff;
|
||||
--surface: #f6f8fb;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.connector-card {
|
||||
width: min(100%, 430px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remember input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-strong);
|
||||
border-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="connector-card" aria-labelledby="title">
|
||||
<div class="brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
|
||||
<strong>NodeWarden</strong>
|
||||
</div>
|
||||
<h1 id="title">Verify your identity</h1>
|
||||
<p id="subtitle">Use your security key to finish two-step verification.</p>
|
||||
<div class="form">
|
||||
<div id="msg" class="msg" role="status" aria-live="polite"></div>
|
||||
<label class="remember">
|
||||
<input id="remember" type="checkbox" />
|
||||
<span id="remember-label">Trust this device for 30 days</span>
|
||||
</label>
|
||||
<button id="webauthn-button" type="button">Read security key</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sentSuccess = false;
|
||||
var allowedParentOriginsPromise = null;
|
||||
|
||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||
|
||||
var titleEl = document.getElementById("title");
|
||||
var subtitleEl = document.getElementById("subtitle");
|
||||
var rememberEl = document.getElementById("remember");
|
||||
var rememberLabelEl = document.getElementById("remember-label");
|
||||
var buttonEl = document.getElementById("webauthn-button");
|
||||
var msgEl = document.getElementById("msg");
|
||||
|
||||
titleEl.textContent = text.title;
|
||||
subtitleEl.textContent = text.subtitle;
|
||||
rememberLabelEl.textContent = text.remember;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
|
||||
buttonEl.addEventListener("click", start);
|
||||
|
||||
function pickText(locale) {
|
||||
var normalized = String(locale || "en").toLowerCase();
|
||||
if (normalized.indexOf("zh") === 0) {
|
||||
return {
|
||||
title: "\u9a8c\u8bc1\u8eab\u4efd",
|
||||
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
|
||||
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
|
||||
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
|
||||
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
|
||||
success: "\u9a8c\u8bc1\u5b8c\u6210",
|
||||
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Verify your identity",
|
||||
subtitle: "Use your security key to finish two-step verification.",
|
||||
remember: "Trust this device for 30 days",
|
||||
button: "Read security key",
|
||||
awaiting: "Awaiting security key interaction...",
|
||||
success: "Verification complete",
|
||||
unsupported: "This browser does not support security keys",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRepeated(value) {
|
||||
if (!value) return "";
|
||||
var out = String(value);
|
||||
for (var i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
var next = decodeURIComponent(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeOrigin(value) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
var url = new URL(value);
|
||||
if (!url.protocol || !url.host) return "";
|
||||
return url.protocol + "//" + url.host;
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionOrigin(origin) {
|
||||
return (
|
||||
origin.indexOf("chrome-extension://") === 0 ||
|
||||
origin.indexOf("moz-extension://") === 0 ||
|
||||
origin.indexOf("safari-web-extension://") === 0
|
||||
);
|
||||
}
|
||||
|
||||
function allowedParentOrigins() {
|
||||
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
|
||||
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "omit",
|
||||
}).then(function (response) {
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
}).then(function (body) {
|
||||
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
|
||||
? body.webAuthnAllowedOrigins
|
||||
: [];
|
||||
return origins.map(normalizeOrigin).filter(Boolean);
|
||||
}).catch(function () {
|
||||
return [];
|
||||
});
|
||||
return allowedParentOriginsPromise;
|
||||
}
|
||||
|
||||
function trustedParentOrigin(allowedOrigins) {
|
||||
var parent = decodeRepeated(params.get("parent"));
|
||||
if (!parent) return "";
|
||||
var parentOrigin = normalizeOrigin(parent);
|
||||
if (!parentOrigin) return "";
|
||||
if (parentOrigin === window.location.origin) {
|
||||
return parentOrigin;
|
||||
}
|
||||
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
|
||||
return parentOrigin;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safeShallowCopy(source) {
|
||||
var copy = {};
|
||||
if (!source || typeof source !== "object") return copy;
|
||||
Object.keys(source).forEach(function (key) {
|
||||
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
|
||||
copy[key] = source[key];
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
async function postResult(message) {
|
||||
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
|
||||
if (parentOrigin) {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(message, parentOrigin);
|
||||
}
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(message, parentOrigin);
|
||||
}
|
||||
}
|
||||
window.postMessage(message, window.location.origin);
|
||||
}
|
||||
|
||||
function showMessage(kind, message) {
|
||||
msgEl.textContent = String(message || "");
|
||||
msgEl.className = "msg show " + kind;
|
||||
}
|
||||
|
||||
function decodeBase64Unicode(value) {
|
||||
var input = String(value || "").replace(/ /g, "+");
|
||||
try {
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
} catch (_error) {
|
||||
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFromBase64Url(value) {
|
||||
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
var binary = atob(normalized);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlFromBuffer(value) {
|
||||
if (!value) return undefined;
|
||||
var bytes = value instanceof Uint8Array
|
||||
? value
|
||||
: new Uint8Array(value);
|
||||
var binary = "";
|
||||
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function readPublicKeyOptions() {
|
||||
var data = params.get("data");
|
||||
if (!data) throw new Error("No data.");
|
||||
var decoded = decodeBase64Unicode(data);
|
||||
if (params.get("v") === "1") {
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
var payload = JSON.parse(decoded);
|
||||
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
|
||||
var copy = safeShallowCopy(options);
|
||||
copy.challenge = bytesFromBase64Url(copy.challenge);
|
||||
if (Array.isArray(copy.allowCredentials)) {
|
||||
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
||||
var next = safeShallowCopy(credential);
|
||||
next.id = bytesFromBase64Url(credential && credential.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function credentialToDataString(credential) {
|
||||
var response = credential.response;
|
||||
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
|
||||
var data = {
|
||||
id: credential.id,
|
||||
rawId: base64UrlFromBuffer(credential.rawId),
|
||||
type: credential.type,
|
||||
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
response: {
|
||||
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
||||
clientDataJson: clientDataJSON,
|
||||
clientDataJSON: clientDataJSON,
|
||||
signature: base64UrlFromBuffer(response.signature),
|
||||
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (sentSuccess) return;
|
||||
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
|
||||
showMessage("error", text.unsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
msgEl.className = "msg";
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
|
||||
var publicKey = normalizeOptions(readPublicKeyOptions());
|
||||
var credential = await navigator.credentials.get({ publicKey: publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error("No security key was selected.");
|
||||
}
|
||||
await postResult({
|
||||
command: "webAuthnResult",
|
||||
data: credentialToDataString(credential),
|
||||
remember: rememberEl.checked,
|
||||
});
|
||||
sentSuccess = true;
|
||||
showMessage("success", text.success);
|
||||
} catch (error) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,10 +13,11 @@ interface AdminPageProps {
|
||||
error: string;
|
||||
onRefresh: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
onDeleteInvalidInvites: () => Promise<void>;
|
||||
onDeleteAllInvites: () => Promise<void>;
|
||||
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onDeleteInvite: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AdminPage(props: AdminPageProps) {
|
||||
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<h3>{t('txt_invites')}</h3>
|
||||
<div className="actions admin-invites-head-actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteInvalidInvites()}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_invalid')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
||||
@@ -184,11 +188,9 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||
</button>
|
||||
{invite.status === 'active' && (
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -46,15 +47,20 @@ function isAdminProfile(profile: Profile | null): boolean {
|
||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
|
||||
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
||||
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||
const isLogRoute = props.location === '/logs';
|
||||
const isAdmin = isAdminProfile(props.profile);
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
|
||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
|
||||
const flatSettingsActive = settingsActive && !deviceManagementActive;
|
||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
|
||||
const managementActive = props.location === '/admin' || props.location === '/logs';
|
||||
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -169,14 +175,15 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
|
||||
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
||||
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
|
||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
|
||||
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
||||
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -190,8 +197,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
|
||||
</>
|
||||
)}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderNavGroup(
|
||||
'settings',
|
||||
@@ -201,6 +210,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
{renderNavGroup(
|
||||
@@ -221,7 +231,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -237,6 +246,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NetworkStatusBadge />
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
@@ -321,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
|
||||
<Sparkles size={18} />
|
||||
<span>{t('nav_generator')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -11,7 +12,10 @@ export interface AppConfirmState {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
hideCancel?: boolean;
|
||||
onConfirm: () => void;
|
||||
/** When true, dialog shows a master-password field and passes it to onConfirm. */
|
||||
requireMasterPassword?: boolean;
|
||||
onConfirm: (masterPassword?: string) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface AppGlobalOverlaysProps {
|
||||
@@ -20,11 +24,14 @@ interface AppGlobalOverlaysProps {
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
pendingTotpAvailableProviders?: number[];
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onSelectTotpProvider: (providerType: number) => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
@@ -36,7 +43,46 @@ interface AppGlobalOverlaysProps {
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_ORDER = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
|
||||
const available = new Set(providerTypes || []);
|
||||
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
|
||||
}
|
||||
|
||||
function twoFactorProviderLabel(providerType: number): string {
|
||||
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
|
||||
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
|
||||
return t('txt_authenticator_app');
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const availableProviders = useMemo(
|
||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||
[props.pendingTotpAvailableProviders]
|
||||
);
|
||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmPassword('');
|
||||
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -48,16 +94,43 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
confirmText={props.confirm?.confirmText}
|
||||
cancelText={props.confirm?.cancelText}
|
||||
hideCancel={props.confirm?.hideCancel}
|
||||
onConfirm={() => props.confirm?.onConfirm()}
|
||||
onCancel={props.onCancelConfirm}
|
||||
/>
|
||||
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
|
||||
onConfirm={() => {
|
||||
if (requireMasterPassword && !confirmPassword.trim()) return;
|
||||
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmPassword('');
|
||||
(props.confirm?.onCancel || props.onCancelConfirm)();
|
||||
}}
|
||||
>
|
||||
{requireMasterPassword && (
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={confirmPassword}
|
||||
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
|
||||
<span className="dialog-title-stack">
|
||||
<span>{t('txt_two_step_verification')}</span>
|
||||
<span>{t('txt_passkey')}</span>
|
||||
</span>
|
||||
) : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
@@ -66,16 +139,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
{alternateProviders.length > 0 && (
|
||||
<div className="two-factor-method-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.totpSubmitting}
|
||||
aria-expanded={methodChooserOpen}
|
||||
onClick={() => setMethodChooserOpen((open) => !open)}
|
||||
>
|
||||
{t('txt_select_another_verification_method')}
|
||||
</button>
|
||||
{methodChooserOpen && (
|
||||
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
|
||||
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
|
||||
{alternateProviders.map((providerType) => (
|
||||
<button
|
||||
key={providerType}
|
||||
type="button"
|
||||
className="btn btn-secondary two-factor-method-option"
|
||||
disabled={props.totpSubmitting}
|
||||
onClick={() => {
|
||||
setMethodChooserOpen(false);
|
||||
props.onSelectTotpProvider(providerType);
|
||||
}}
|
||||
>
|
||||
{twoFactorProviderLabel(providerType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
@@ -87,7 +196,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
|
||||
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||
@@ -39,6 +41,7 @@ export interface AppMainRoutesProps {
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
themePreference: 'system' | 'light' | 'dark';
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -54,9 +57,12 @@ export interface AppMainRoutesProps {
|
||||
adminLoading: boolean;
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
currentDeviceIdentifier: string;
|
||||
authorizedDevicesLoading: boolean;
|
||||
authorizedDevicesError: string;
|
||||
domainRules: DomainRules | null;
|
||||
@@ -65,6 +71,7 @@ export interface AppMainRoutesProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
@@ -107,11 +114,31 @@ export interface AppMainRoutesProps {
|
||||
sendUploadPercent: number | null;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
pendingAuthRequestsRefreshing: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
@@ -121,34 +148,37 @@ export interface AppMainRoutesProps {
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||
onRevokeAllDeviceTrust: () => void;
|
||||
onRemoveAllDevices: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
onRefreshAdmin: () => void;
|
||||
onDeleteInvalidInvites: () => Promise<void>;
|
||||
onDeleteAllInvites: () => Promise<void>;
|
||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onDeleteInvite: (code: string) => Promise<void>;
|
||||
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
||||
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
||||
onClearAuditLogs: () => Promise<number>;
|
||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
}
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
|
||||
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
||||
const importPageContent = (
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
@@ -179,6 +209,16 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/security/password-health">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/generator">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordGeneratorPage />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/sends">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SendsPage
|
||||
@@ -252,15 +292,34 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
passkey2faEnabled={props.passkey2faEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onThemePreferenceChange={props.onThemePreferenceChange}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetYubiKeySettings={props.onGetYubiKeySettings}
|
||||
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onListAccountPasskeys={props.onListAccountPasskeys}
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
@@ -273,43 +332,59 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{props.profile ? (
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||
<Globe2 size={18} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
<Link href={props.importRoute} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
<section className="card mobile-settings-card settings-home-card">
|
||||
<div className="settings-home-section">
|
||||
<h3>{t('txt_settings')}</h3>
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/logs" className="mobile-settings-link">
|
||||
<FileClock size={18} />
|
||||
<span>{t('nav_log_center')}</span>
|
||||
<Link href="/security/password-health" className="mobile-settings-link">
|
||||
<ShieldCheck size={18} />
|
||||
<span>{t('nav_password_security')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||
<Globe2 size={18} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-home-section">
|
||||
<h3>{t('nav_group_data_backup')}</h3>
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.importRoute} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="settings-home-section">
|
||||
<h3>{t('nav_group_management')}</h3>
|
||||
<div className="mobile-settings-links">
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
<Link href="/logs" className="mobile-settings-link">
|
||||
<FileClock size={18} />
|
||||
<span>{t('nav_log_center')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-home-spacer" />
|
||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||
<LogOut size={14} className="btn-icon" />
|
||||
{t('txt_sign_out')}
|
||||
@@ -319,32 +394,42 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<LoadingState card lines={4} />
|
||||
) : null}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onTrustPermanently={props.onTrustDevicePermanently}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
{deviceManagementRoutePaths.map((path) => (
|
||||
<Route key={path} path={path}>
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
currentDeviceIdentifier={props.currentDeviceIdentifier}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onTrustPermanently={props.onTrustDevicePermanently}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
))}
|
||||
<Route path="/settings/domain-rules">
|
||||
<div className="stack domain-rules-route">
|
||||
{props.mobileLayout && (
|
||||
@@ -386,10 +471,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
error={props.adminError}
|
||||
onRefresh={props.onRefreshAdmin}
|
||||
onCreateInvite={props.onCreateInvite}
|
||||
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
|
||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||
onToggleUserStatus={props.onToggleUserStatus}
|
||||
onDeleteUser={props.onDeleteUser}
|
||||
onRevokeInvite={props.onRevokeInvite}
|
||||
onDeleteInvite={props.onDeleteInvite}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ShieldCheck, ShieldX } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AuthRequest } from '@/lib/types';
|
||||
|
||||
interface AuthRequestApprovalDialogProps {
|
||||
open: boolean;
|
||||
authRequest: AuthRequest | null;
|
||||
submitting: boolean;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
|
||||
const authRequest = props.authRequest;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={props.open && !!authRequest}
|
||||
title={t('txt_approve_device_login')}
|
||||
message={t('txt_auth_request_approve_message')}
|
||||
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
|
||||
cancelText={t('txt_later')}
|
||||
confirmDisabled={props.submitting || !authRequest}
|
||||
cancelDisabled={props.submitting}
|
||||
onConfirm={props.onApprove}
|
||||
onCancel={props.onClose}
|
||||
afterActions={(
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger dialog-btn"
|
||||
disabled={props.submitting || !authRequest}
|
||||
onClick={props.onDeny}
|
||||
>
|
||||
<ShieldX size={14} className="btn-icon" />
|
||||
{t('txt_deny')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{authRequest && (
|
||||
<div className="auth-request-details">
|
||||
<div className="auth-request-device">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
|
||||
<small>{authRequest.requestDeviceIdentifier}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-request-kv">
|
||||
<span>{t('txt_created')}</span>
|
||||
<strong>{formatDateTime(authRequest.creationDate)}</strong>
|
||||
</div>
|
||||
{authRequest.requestIpAddress && (
|
||||
<div className="auth-request-kv">
|
||||
<span>{t('txt_ip_address')}</span>
|
||||
<strong>{authRequest.requestIpAddress}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-request-fingerprint">
|
||||
<span>{t('txt_fingerprint_phrase')}</span>
|
||||
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
|
||||
|
||||
interface LoginValues {
|
||||
email: string;
|
||||
@@ -22,19 +24,25 @@ interface AuthViewsProps {
|
||||
relaxedLoginInput?: boolean;
|
||||
authPlaceholder?: string;
|
||||
unlockPlaceholder?: string;
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
loginValues: LoginValues;
|
||||
pendingPasskeyPasswordEmail?: string | null;
|
||||
passkeyPassword: string;
|
||||
registerValues: RegisterValues;
|
||||
registrationInviteRequired?: boolean;
|
||||
unlockPassword: string;
|
||||
emailForLock: string;
|
||||
loginHintLoading: boolean;
|
||||
onChangeLogin: (next: LoginValues) => void;
|
||||
onChangePasskeyPassword: (password: string) => void;
|
||||
onChangeRegister: (next: RegisterValues) => void;
|
||||
onChangeUnlock: (password: string) => void;
|
||||
onSubmitLogin: () => void;
|
||||
onSubmitPasskey: () => void;
|
||||
onSubmitPasskeyUnlock: () => void;
|
||||
onSubmitPasskeyPassword: () => void;
|
||||
onSubmitRegister: () => void;
|
||||
onSubmitUnlock: () => void;
|
||||
onGotoLogin: () => void;
|
||||
@@ -74,22 +82,55 @@ function PasswordField(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function OfflineModeNotice() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setStatus), []);
|
||||
|
||||
if (status !== 'offline') return null;
|
||||
|
||||
return (
|
||||
<div className="offline-mode-notice" role="alert" aria-live="assertive">
|
||||
<div>
|
||||
<strong>{t('txt_offline_mode_notice_title')}</strong>
|
||||
<div className="offline-shortcut-list">
|
||||
<div className="offline-shortcut-row">
|
||||
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
|
||||
<span className="offline-shortcut-value">
|
||||
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="offline-shortcut-row">
|
||||
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
|
||||
<span className="offline-shortcut-value">
|
||||
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthViews(props: AuthViewsProps) {
|
||||
const loginBusy = props.pendingAction === 'login';
|
||||
const passkeyBusy = props.pendingAction === 'passkey';
|
||||
const registerBusy = props.pendingAction === 'register';
|
||||
const unlockBusy = props.pendingAction === 'unlock';
|
||||
const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail;
|
||||
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
||||
|
||||
if (props.mode === 'locked') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmitUnlock();
|
||||
}}
|
||||
>
|
||||
<OfflineModeNotice />
|
||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||
<PasswordField
|
||||
@@ -114,12 +155,21 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
{props.unlockPreparing ? (
|
||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary full"
|
||||
onClick={props.onSubmitPasskeyUnlock}
|
||||
disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}
|
||||
>
|
||||
<KeyRound size={16} className="btn-icon" />
|
||||
{passkeyBusy ? t('txt_unlocking') : t('txt_unlock_with_passkey')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy || passkeyBusy}>
|
||||
<LogOut size={16} className="btn-icon" />
|
||||
{t('txt_log_out')}
|
||||
</button>
|
||||
@@ -132,7 +182,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_create_account')}>
|
||||
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -216,13 +266,42 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_log_in')}>
|
||||
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (passkeyPasswordPending) {
|
||||
props.onSubmitPasskeyPassword();
|
||||
return;
|
||||
}
|
||||
props.onSubmitLogin();
|
||||
}}
|
||||
>
|
||||
<OfflineModeNotice />
|
||||
{passkeyPasswordPending ? (
|
||||
<>
|
||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||
<input type="text" value={props.pendingPasskeyPasswordEmail || ''} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||
<PasswordField
|
||||
label={t('txt_master_password')}
|
||||
value={props.passkeyPassword}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder={props.authPlaceholder}
|
||||
onInput={props.onChangePasskeyPassword}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{loginBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={loginBusy}>
|
||||
<ArrowLeft size={16} className="btn-icon" />
|
||||
{t('txt_back_to_login')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>{t('txt_email')}</span>
|
||||
<input
|
||||
@@ -255,15 +334,21 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
: t('txt_show_password_hint')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
|
||||
<button type="submit" className="btn btn-primary full" disabled={loginBusy || passkeyBusy}>
|
||||
<LogIn size={16} className="btn-icon" />
|
||||
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}>
|
||||
<KeyRound size={16} className="btn-icon" />
|
||||
{passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy || passkeyBusy}>
|
||||
<UserPlus size={16} className="btn-icon" />
|
||||
{t('txt_create_account')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</StandalonePageFrame>
|
||||
</div>
|
||||
|
||||
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
||||
|
||||
interface BackupCenterPageProps {
|
||||
currentUserId: string | null;
|
||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ type PendingRestoreIntegrity =
|
||||
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
||||
|
||||
type PendingBackupVerification =
|
||||
| { action: 'export' }
|
||||
| { action: 'saveSettings' }
|
||||
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
|
||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||
| { action: 'runRemoteBackup' }
|
||||
| { action: 'downloadRemote'; path: string }
|
||||
| { action: 'deleteRemote'; destinationId: string; path: string }
|
||||
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
||||
|
||||
interface BackupProgressPhase {
|
||||
titleKey: string;
|
||||
detailKey: string;
|
||||
@@ -184,7 +194,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [, setLocalError] = useState('');
|
||||
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||
@@ -193,6 +203,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
||||
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
||||
const [backupPasswordError, setBackupPasswordError] = useState('');
|
||||
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||
@@ -209,7 +223,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
||||
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
||||
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
|
||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
|
||||
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
||||
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
||||
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
||||
@@ -226,6 +240,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
||||
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
||||
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
||||
const backupPasswordPromptTitle =
|
||||
pendingBackupVerification?.action === 'export'
|
||||
? t('txt_backup_export')
|
||||
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
|
||||
? t('txt_backup_save_settings')
|
||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||
? t('txt_backup_run_manual')
|
||||
: pendingBackupVerification?.action === 'downloadRemote'
|
||||
? t('txt_backup_remote_download')
|
||||
: pendingBackupVerification?.action === 'deleteRemote'
|
||||
? t('txt_delete')
|
||||
: pendingBackupVerification?.action === 'restoreRemote'
|
||||
? t('txt_backup_import')
|
||||
: t('txt_backup_import');
|
||||
|
||||
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
|
||||
setPendingBackupVerification(request);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}
|
||||
|
||||
function showActionError(error: unknown, fallback: string): string {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
setLocalError(message);
|
||||
if (backupPasswordSubmitting || pendingBackupVerification) {
|
||||
setBackupPasswordError(message);
|
||||
}
|
||||
props.onNotify('error', message);
|
||||
return message;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -448,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||
}
|
||||
|
||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
||||
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||
return {
|
||||
source: 'remote',
|
||||
path,
|
||||
@@ -478,10 +522,15 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||
};
|
||||
|
||||
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const saved = await props.onSaveSettings(nextSettings);
|
||||
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||
setSavedSettings(saved);
|
||||
@@ -497,27 +546,32 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setSelectedDestinationId(nextSelected);
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
props.onNotify('success', t('txt_backup_destination_deleted'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting) return;
|
||||
openBackupPasswordPrompt({ action: 'export' });
|
||||
}
|
||||
|
||||
async function executeExport(masterPassword: string): Promise<boolean> {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||
await props.onExport(exportIncludeAttachments);
|
||||
await props.onExport(masterPassword, exportIncludeAttachments);
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_export_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -535,6 +589,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
}
|
||||
openBackupPasswordPrompt({
|
||||
action: 'import',
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeLocalRestore(
|
||||
masterPassword: string,
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
): Promise<boolean> {
|
||||
if (importing) return false;
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
setBackupPasswordError(message);
|
||||
props.onNotify('error', message);
|
||||
return false;
|
||||
}
|
||||
setLocalError('');
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(false);
|
||||
@@ -547,8 +623,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
||||
: await props.onImport(selectedFile, replaceExisting);
|
||||
? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
|
||||
: await props.onImport(masterPassword, selectedFile, replaceExisting);
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
@@ -556,29 +632,34 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(false);
|
||||
resetPendingIntegrityWarning();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
clearRestoreProgress();
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_restore_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (savingSettings) return;
|
||||
openBackupPasswordPrompt({ action: 'saveSettings' });
|
||||
}
|
||||
|
||||
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
|
||||
const payload = buildSettingsPayloadForSelectedDestination();
|
||||
const destinationIdToInvalidate = selectedDestinationId;
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const saved = await props.onSaveSettings(payload);
|
||||
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||
const nextSelected =
|
||||
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
||||
|| getFirstVisibleDestinationId(saved)
|
||||
@@ -592,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
setSelectedDestinationId(nextSelected);
|
||||
props.onNotify('success', t('txt_backup_settings_saved'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
@@ -613,7 +694,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleRunRemoteBackup() {
|
||||
if (!selectedDestination) return;
|
||||
if (!selectedDestination || runningRemoteBackup) return;
|
||||
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
|
||||
}
|
||||
|
||||
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
|
||||
if (!selectedDestination) return false;
|
||||
setRunningRemoteBackup(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
@@ -621,33 +707,37 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
source: 'remote',
|
||||
includeAttachments: !!selectedDestination.includeAttachments,
|
||||
});
|
||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
||||
const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
|
||||
setSavedSettings(result.settings);
|
||||
setSettings(result.settings);
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_run_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setRunningRemoteBackup(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadRemote(path: string) {
|
||||
if (!savedSelectedDestination) return;
|
||||
openBackupPasswordPrompt({ action: 'downloadRemote', path });
|
||||
}
|
||||
|
||||
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
|
||||
if (!savedSelectedDestination) return false;
|
||||
setDownloadingRemotePath(path);
|
||||
setDownloadingRemotePercent(null);
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_download_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setDownloadingRemotePath('');
|
||||
setDownloadingRemotePercent(null);
|
||||
@@ -657,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
async function handleDeleteRemote(path: string) {
|
||||
if (deletingRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
|
||||
setConfirmRemoteDeleteOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
|
||||
if (deletingRemotePath) return false;
|
||||
setDeletingRemotePath(path);
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
|
||||
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
|
||||
setConfirmRemoteDeleteOpen(false);
|
||||
setPendingRemoteDeletePath('');
|
||||
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_delete_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingRemotePath('');
|
||||
}
|
||||
@@ -704,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
if (!savedSelectedDestination) return;
|
||||
setLocalError('');
|
||||
resetPendingIntegrityWarning();
|
||||
try {
|
||||
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||
if (!integrity.result.matches) {
|
||||
setPendingRestoreIntegrity(integrity);
|
||||
setConfirmIntegrityWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
await runRemoteRestore(path, false, false, integrity.result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
}
|
||||
await runRemoteRestore(path, false);
|
||||
}
|
||||
|
||||
async function runRemoteRestore(
|
||||
@@ -727,41 +811,114 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
openBackupPasswordPrompt({
|
||||
action: 'restoreRemote',
|
||||
path,
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeRemoteRestore(
|
||||
masterPassword: string,
|
||||
path: string,
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
): Promise<boolean> {
|
||||
if (restoringRemotePath) return false;
|
||||
if (!savedSelectedDestination) return false;
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setConfirmIntegrityWarningOpen(false);
|
||||
setRestoringRemotePath(path);
|
||||
setLocalError('');
|
||||
try {
|
||||
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||
const integrity = knownIntegrity
|
||||
? { result: knownIntegrity }
|
||||
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
|
||||
if (!allowChecksumMismatch && !integrity.result.matches) {
|
||||
setPendingRestoreIntegrity(
|
||||
'source' in integrity
|
||||
? integrity
|
||||
: {
|
||||
source: 'remote',
|
||||
path,
|
||||
fileName: path.split('/').pop() || path,
|
||||
result: integrity.result,
|
||||
}
|
||||
);
|
||||
setConfirmIntegrityWarningOpen(true);
|
||||
return true;
|
||||
}
|
||||
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||
source: 'remote',
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
||||
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
|
||||
: await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
resetPendingIntegrityWarning();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setPendingRemoteRestorePath(path);
|
||||
setConfirmRemoteReplaceOpen(true);
|
||||
clearRestoreProgress();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
showActionError(error, t('txt_backup_remote_restore_failed'));
|
||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||
return false;
|
||||
} finally {
|
||||
setRestoringRemotePath('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBackupPasswordPrompt(): Promise<void> {
|
||||
const request = pendingBackupVerification;
|
||||
const masterPassword = backupPasswordValue;
|
||||
if (!request || backupPasswordSubmitting) return;
|
||||
if (!masterPassword.trim()) {
|
||||
setBackupPasswordError(t('txt_master_password_is_required'));
|
||||
return;
|
||||
}
|
||||
setBackupPasswordSubmitting(true);
|
||||
setBackupPasswordError('');
|
||||
let succeeded = false;
|
||||
try {
|
||||
if (request.action === 'export') {
|
||||
succeeded = await executeExport(masterPassword);
|
||||
} else if (request.action === 'saveSettings') {
|
||||
succeeded = await executeSaveSettings(masterPassword);
|
||||
} else if (request.action === 'deleteDestination') {
|
||||
succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||
} else if (request.action === 'import') {
|
||||
succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
} else if (request.action === 'runRemoteBackup') {
|
||||
succeeded = await executeRunRemoteBackup(masterPassword);
|
||||
} else if (request.action === 'downloadRemote') {
|
||||
succeeded = await executeDownloadRemote(masterPassword, request.path);
|
||||
} else if (request.action === 'deleteRemote') {
|
||||
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
|
||||
} else if (request.action === 'restoreRemote') {
|
||||
succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
}
|
||||
} finally {
|
||||
setBackupPasswordSubmitting(false);
|
||||
}
|
||||
if (succeeded) {
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="backup-grid">
|
||||
<input
|
||||
@@ -848,7 +1005,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{localError ? <div className="local-error">{localError}</div> : null}
|
||||
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||
<div className="restore-progress-overlay" aria-live="polite">
|
||||
<section className="restore-progress-card restore-progress-modal">
|
||||
@@ -893,6 +1049,43 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingBackupVerification !== null}
|
||||
title={backupPasswordPromptTitle}
|
||||
message={t('txt_enter_master_password_to_continue')}
|
||||
confirmText={t('txt_continue')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
|
||||
cancelDisabled={backupPasswordSubmitting}
|
||||
onConfirm={() => void submitBackupPasswordPrompt()}
|
||||
onCancel={() => {
|
||||
if (backupPasswordSubmitting) return;
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
setBackupPasswordError('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
id="backup-master-password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={backupPasswordValue}
|
||||
aria-invalid={!!backupPasswordError}
|
||||
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
|
||||
onInput={(event) => {
|
||||
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
|
||||
if (backupPasswordError) setBackupPasswordError('');
|
||||
}}
|
||||
/>
|
||||
{backupPasswordError ? (
|
||||
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
|
||||
) : null}
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
title={t('txt_backup_import')}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { TriangleAlert, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
title: ComponentChildren;
|
||||
message?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
hideCancel?: boolean;
|
||||
hideConfirm?: boolean;
|
||||
closeButton?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
@@ -83,14 +85,18 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [present, setPresent] = useState(props.open);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||
const maskPointerStartedRef = useRef(false);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const hasMessage = !!props.message;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
lastTitleRef.current = props.title;
|
||||
setPresent(true);
|
||||
setClosing(false);
|
||||
return;
|
||||
@@ -176,8 +182,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
return createPortal((
|
||||
<div
|
||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onPointerDown={(event) => {
|
||||
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
@@ -187,7 +196,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
aria-describedby={hasMessage ? messageId : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
@@ -207,17 +216,33 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.closeButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close-btn"
|
||||
aria-label={t('txt_close')}
|
||||
disabled={props.cancelDisabled}
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
|
||||
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
)}
|
||||
{!props.hideCancel && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { strFromU8, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
@@ -91,10 +91,17 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||
'lastpass',
|
||||
'dashlane_csv',
|
||||
'dashlane_json',
|
||||
'keepass_csv',
|
||||
'keepass_xml',
|
||||
'keepassx_csv',
|
||||
];
|
||||
|
||||
const MAX_IMPORT_ZIP_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_IMPORT_ZIP_ENTRY_COUNT = 10_000;
|
||||
const MAX_IMPORT_TEXT_ENTRY_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_IMPORT_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_IMPORT_ATTACHMENT_TOTAL_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
@@ -170,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||
}
|
||||
|
||||
function formatMiB(bytes: number): string {
|
||||
return String(Math.floor(bytes / (1024 * 1024)));
|
||||
}
|
||||
|
||||
function zipEntryName(rawName: unknown): string {
|
||||
return String(rawName || '').trim().replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function assertSafeZipEntryName(name: string): void {
|
||||
if (!name || name.includes('\0') || name.startsWith('/') || name.includes('//')) {
|
||||
throw new Error(t('txt_import_zip_unsafe_file_name'));
|
||||
}
|
||||
const parts = name.split('/');
|
||||
if (parts.some((part) => part === '.' || part === '..')) {
|
||||
throw new Error(t('txt_import_zip_unsafe_file_name'));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportZipSize(bytes: number): void {
|
||||
if (bytes > MAX_IMPORT_ZIP_BYTES) {
|
||||
throw new Error(t('txt_import_zip_too_large', { size: formatMiB(MAX_IMPORT_ZIP_BYTES) }));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportTextFileSize(bytes: number): void {
|
||||
if (bytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
|
||||
throw new Error(t('txt_import_file_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
}
|
||||
|
||||
function assertImportEntrySize(size: number, maxBytes: number): void {
|
||||
if (size > maxBytes) {
|
||||
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(maxBytes) }));
|
||||
}
|
||||
}
|
||||
|
||||
function isImportTextZipCandidate(source: ImportSourceId, name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
if (source === 'onepassword_1pux') {
|
||||
return lower.endsWith('/export.data') || lower === 'export.data' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
|
||||
}
|
||||
return lower.endsWith('/protonpass.json') || lower === 'protonpass.json' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
|
||||
}
|
||||
|
||||
function createImportTextZipFilter(source: ImportSourceId): (file: UnzipFileInfo) => boolean {
|
||||
let entryCount = 0;
|
||||
let totalTextBytes = 0;
|
||||
return (entry: UnzipFileInfo): boolean => {
|
||||
entryCount += 1;
|
||||
if (entryCount > MAX_IMPORT_ZIP_ENTRY_COUNT) {
|
||||
throw new Error(t('txt_import_zip_too_many_files'));
|
||||
}
|
||||
const name = zipEntryName(entry.name);
|
||||
assertSafeZipEntryName(name);
|
||||
if (!isImportTextZipCandidate(source, name)) return false;
|
||||
|
||||
const originalSize = Number(entry.originalSize);
|
||||
if (!Number.isFinite(originalSize) || originalSize < 0) {
|
||||
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
assertImportEntrySize(originalSize, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
totalTextBytes += originalSize;
|
||||
if (totalTextBytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
const unzipped = unzipSync(bytes);
|
||||
assertImportZipSize(bytes.byteLength);
|
||||
const unzippedRaw = unzipSync(bytes, { filter: createImportTextZipFilter(source) });
|
||||
const unzipped: Record<string, Uint8Array> = {};
|
||||
for (const [rawName, entryBytes] of Object.entries(unzippedRaw)) {
|
||||
const name = zipEntryName(rawName);
|
||||
assertSafeZipEntryName(name);
|
||||
assertImportEntrySize(entryBytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
unzipped[name] = entryBytes;
|
||||
}
|
||||
const fileNames = Object.keys(unzipped);
|
||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
@@ -188,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
|
||||
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||
assertImportTextFileSize(file.size);
|
||||
return file.text();
|
||||
}
|
||||
assertImportZipSize(file.size);
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||
assertImportTextFileSize(bytes.byteLength);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
@@ -210,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
|
||||
return message.includes('password') || message.includes('encrypted');
|
||||
}
|
||||
|
||||
function bitwardenZipAttachmentMatch(name: string): RegExpMatchArray | null {
|
||||
return name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
}
|
||||
|
||||
function zipJsEntrySize(entry: unknown): number | null {
|
||||
const size = Number((entry as { uncompressedSize?: unknown })?.uncompressedSize);
|
||||
return Number.isFinite(size) && size >= 0 ? size : null;
|
||||
}
|
||||
|
||||
function validateBitwardenZipEntries(entries: Awaited<ReturnType<ZipReader<unknown>['getEntries']>>): void {
|
||||
if (entries.length > MAX_IMPORT_ZIP_ENTRY_COUNT) {
|
||||
throw new Error(t('txt_import_zip_too_many_files'));
|
||||
}
|
||||
|
||||
let totalAttachmentBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = zipEntryName(entry.filename);
|
||||
assertSafeZipEntryName(name);
|
||||
const lower = name.toLowerCase();
|
||||
const size = zipJsEntrySize(entry);
|
||||
if (lower === 'data.json' && size != null) {
|
||||
assertImportEntrySize(size, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
} else if (bitwardenZipAttachmentMatch(name) && size != null) {
|
||||
assertImportEntrySize(size, MAX_IMPORT_ATTACHMENT_BYTES);
|
||||
totalAttachmentBytes += size;
|
||||
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readBitwardenZipPayload(
|
||||
file: File,
|
||||
passwordRaw: string
|
||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||
const password = String(passwordRaw || '').trim();
|
||||
assertImportZipSize(file.size);
|
||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
validateBitwardenZipEntries(entries);
|
||||
|
||||
let jsonText = '';
|
||||
let totalAttachmentBytes = 0;
|
||||
const attachments: ImportAttachmentFile[] = [];
|
||||
const options = password ? { password } : undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||
const name = zipEntryName(entry.filename);
|
||||
if (!name) continue;
|
||||
assertSafeZipEntryName(name);
|
||||
|
||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'data.json') {
|
||||
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||
jsonText = new TextDecoder().decode(bytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
const attachmentMatch = bitwardenZipAttachmentMatch(name);
|
||||
if (!attachmentMatch) continue;
|
||||
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_ATTACHMENT_BYTES);
|
||||
totalAttachmentBytes += bytes.byteLength;
|
||||
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
|
||||
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
|
||||
}
|
||||
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||
attachments.push({
|
||||
|
||||