mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
128
Commits
v1.7.0
..
a21cc81da0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21cc81da0 | ||
|
|
b731a014f1 | ||
|
|
e25ec159bb | ||
|
|
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 |
@@ -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
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Project Wiki/ 项目文档
|
- name: Project Wiki/ 项目文档
|
||||||
url: https://github.com/shuaiplus/nodewarden/wiki
|
url: https://nodewarden.app
|
||||||
about: |
|
about: |
|
||||||
Please check the documentation for common questions and troubleshooting steps.
|
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:
|
sync-global-domains:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Sync generated Bitwarden domains
|
- 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
|
- name: Verify custom domains were not touched
|
||||||
run: git diff --exit-code -- src/static/global_domains.custom.json
|
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||||
|
|
||||||
- name: Create pull request
|
- name: Create pull request
|
||||||
uses: peter-evans/create-pull-request@v6
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||||
with:
|
with:
|
||||||
branch: chore/sync-bitwarden-global-domains
|
branch: chore/sync-bitwarden-global-domains
|
||||||
delete-branch: true
|
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
|
|
||||||
+3
-1
@@ -43,7 +43,7 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
.tmp-bitwarden-clients/
|
.tmp-bitwarden-clients/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden-wiki/
|
||||||
wiki/
|
wiki/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
settings.json
|
settings.json
|
||||||
@@ -56,9 +56,11 @@ NodeWarden-compat/
|
|||||||
.codex-upstream/bitwarden-browser/
|
.codex-upstream/bitwarden-browser/
|
||||||
|
|
||||||
.reasonix/
|
.reasonix/
|
||||||
|
.upstream/
|
||||||
|
|
||||||
# Compatibility analysis documents
|
# Compatibility analysis documents
|
||||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||||
|
security-audits/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
opencode.jsonc
|
opencode.jsonc
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|||||||
@@ -3,95 +3,100 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./README_EN.md">English</a> |
|
<a href="./README_ZH.md">中文</a> |
|
||||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||||
|
<a href="https://nodewarden.app">Official wiki</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **Disclaimer**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## Feature comparison with the official Bitwarden server
|
||||||
|
|
||||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||||
|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||||
|
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已测试客户端
|
## Tested clients
|
||||||
|
|
||||||
- ✅ Windows 桌面端
|
- ✅ Windows desktop
|
||||||
- ✅ 手机 App
|
- ✅ Mobile app
|
||||||
- ✅ 浏览器扩展
|
- ✅ Browser extension
|
||||||
- ✅ Linux 桌面端
|
- ✅ Linux desktop
|
||||||
- ⚠️ macOS 桌面端尚未完整验证
|
- ⚠️ macOS desktop not fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 可视化快速部署
|
## Visual quick deploy
|
||||||
|
|
||||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
1. Fork the NodeWarden repository to your GitHub account
|
||||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
3. 选择 Continue with GitHub 并选择你的仓库
|
3. Choose **Continue with GitHub** and select your fork
|
||||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||||
5. 等部署完成后,打开生成的 Workers 域名
|
5. After deployment finishes, open the generated Workers URL
|
||||||
|
|
||||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
|
||||||
|
|
||||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
|
||||||
|
|
||||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 默认R2与可选KV的区别:
|
> Default R2 vs optional KV:
|
||||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||||
> |---|---|---|---|
|
> |---|---|---|---|
|
||||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
## 更新方法:
|
## How to update
|
||||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
|
||||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI 部署
|
|
||||||
|
## CLI deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
@@ -100,85 +105,46 @@ cd NodeWarden
|
|||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 默认:R2 模式
|
# Default: R2 mode
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# 可选:KV 模式
|
# Optional: KV mode
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# 本地开发
|
# Local development
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 主要特性
|
|
||||||
|
|
||||||
### PWA 渐进式 Web 应用
|
## License
|
||||||
|
|
||||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
|
||||||
- ✅ **离线使用** - Service Worker 缓存,离线也能查看密码
|
|
||||||
- ✅ **App 快捷方式** - 快速启动保险库、TOTP代码
|
|
||||||
- ✅ **后台解密** - Web Worker 处理解密,不阻塞UI
|
|
||||||
|
|
||||||
### Passkey 无密码登录
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
|
|
||||||
- ✅ **PRF 密钥解锁** - Passkey 可直接解锁保险库
|
|
||||||
- ✅ **官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
|
|
||||||
- ✅ **多设备同步** - 支持iCloud、Google Password Manager等
|
|
||||||
|
|
||||||
### 云端备份说明
|
|
||||||
|
|
||||||
- 远程备份支持 **WebDAV** 与 **S3**
|
|
||||||
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
|
|
||||||
- 勾选”包含附件”后:
|
|
||||||
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
|
||||||
- 真实附件单独存放在 `attachments/`
|
|
||||||
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
|
||||||
- 远程还原时:
|
|
||||||
- 会从 `attachments/` 目录按需读取附件
|
|
||||||
- 缺失的附件会被安全跳过
|
|
||||||
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 导入 / 导出
|
|
||||||
|
|
||||||
当前支持的导入来源包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden 密码库 + 附件 ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
|
||||||
|
|
||||||
当前支持的导出方式包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden 加密 JSON
|
|
||||||
- 带附件的 ZIP 导出
|
|
||||||
- NodeWarden JSON 系列
|
|
||||||
- 备份中心中的实例级完整手动导出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 致谢
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
Bitwarden-compatible server running on Cloudflare Workers
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="./README.md">中文说明</a> |
|
|
||||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> **Disclaimer**
|
|
||||||
>
|
|
||||||
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
|
||||||
>
|
|
||||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Comparison with the Official Bitwarden Server
|
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
|
||||||
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
|
||||||
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
|
||||||
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
|
||||||
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
|
|
||||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
|
||||||
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
|
|
||||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
|
||||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
|
||||||
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
|
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tested Clients
|
|
||||||
|
|
||||||
- ✅ Windows desktop client
|
|
||||||
- ✅ Mobile app
|
|
||||||
- ✅ Browser extension
|
|
||||||
- ✅ Linux desktop client
|
|
||||||
- ⚠️ macOS desktop client has not been fully verified yet
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Deploy
|
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, consider giving it a Star.
|
|
||||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
|
||||||
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
|
||||||
4. Deploy and open the generated URL.
|
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
|
||||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> How to keep your fork updated:
|
|
||||||
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
|
||||||
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
|
||||||
|
|
||||||
## CLI Deploy
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm install
|
|
||||||
npx wrangler login
|
|
||||||
|
|
||||||
# Default: R2 mode
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# Optional: KV mode
|
|
||||||
npm run deploy:kv
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
npm run dev
|
|
||||||
npm run dev:kv
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### PWA Progressive Web App
|
|
||||||
|
|
||||||
- ✅ **Install to desktop** - Runs like a native app
|
|
||||||
- ✅ **Offline usage** - Service Worker caching, view passwords offline
|
|
||||||
- ✅ **App shortcuts** - Quick launch vault, TOTP codes
|
|
||||||
- ✅ **Background decryption** - Web Worker handles decryption without blocking UI
|
|
||||||
|
|
||||||
### Passkey Passwordless Login
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
|
|
||||||
- ✅ **PRF key unlock** - Passkey can unlock vault directly
|
|
||||||
- ✅ **Official client compatibility** - Chromium browser extension supports Passkey login
|
|
||||||
- ✅ **Multi-device sync** - Supports iCloud, Google Password Manager, etc.
|
|
||||||
|
|
||||||
### Cloud Backup Notes
|
|
||||||
|
|
||||||
- Remote backup supports **WebDAV** and **S3**
|
|
||||||
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
|
|
||||||
- When `Include attachments` is enabled:
|
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
|
||||||
- actual attachment files are stored separately under `attachments/`
|
|
||||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
|
||||||
- During remote restore:
|
|
||||||
- required attachment files are loaded from `attachments/` on demand
|
|
||||||
- missing attachments are skipped safely
|
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Import / Export
|
|
||||||
|
|
||||||
Current supported import sources include:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden vault + attachments ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- Multiple browser / password-manager formats available in the web import selector
|
|
||||||
|
|
||||||
Current supported export formats include:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden encrypted JSON
|
|
||||||
- ZIP export with attachments
|
|
||||||
- NodeWarden JSON variants
|
|
||||||
- Full manual instance export from the backup center
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.md">English</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||||
|
<a href="https://nodewarden.app">官方wiki</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> **免责声明**
|
||||||
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
|
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
|
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||||
|
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||||
|
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||||
|
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||||
|
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||||
|
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||||
|
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||||
|
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||||
|
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||||
|
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||||
|
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||||
|
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||||
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已测试客户端
|
||||||
|
|
||||||
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可视化快速部署
|
||||||
|
|
||||||
|
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||||
|
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
|
3. 选择 Continue with GitHub 并选择你的仓库
|
||||||
|
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||||
|
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
|
5. 等部署完成后,打开生成的 Workers 域名
|
||||||
|
|
||||||
|
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||||
|
|
||||||
|
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||||
|
|
||||||
|
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 默认R2与可选KV的区别:
|
||||||
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## CLI 部署
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 默认:R2 模式
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# 可选:KV 模式
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
verify_devices INTEGER NOT NULL DEFAULT 0,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
device_identifier TEXT,
|
device_identifier TEXT,
|
||||||
device_session_stamp TEXT,
|
device_session_stamp TEXT,
|
||||||
|
security_stamp TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
absolute_expires_at INTEGER,
|
||||||
|
client_type TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
@@ -228,9 +233,20 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
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 (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL DEFAULT 'login',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
credential_id TEXT NOT NULL,
|
credential_id TEXT NOT NULL,
|
||||||
|
|||||||
Generated
+1822
-969
File diff suppressed because it is too large
Load Diff
+27
-20
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.0",
|
"version": "1.7.4",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
@@ -42,28 +42,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.28.0",
|
||||||
|
"@babel/core": ">=7.29.6",
|
||||||
|
"esbuild": ">=0.28.1",
|
||||||
|
"ws": "8.21.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260630.1",
|
||||||
"@preact/preset-vite": "^2.10.3",
|
"@preact/preset-vite": "^2.10.5",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^26.0.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.5.2",
|
||||||
"opencc-js": "^1.0.5",
|
"opencc-js": "^1.3.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.16",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.1.3",
|
||||||
"wrangler": "^4.71.0"
|
"wrangler": "^4.105.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@simplewebauthn/server": "^13.3.1",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.26",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.3",
|
||||||
"lucide-preact": "^0.575.0",
|
"jsqr": "1.4.0",
|
||||||
"preact": "^10.28.4",
|
"lucide-preact": "^1.22.0",
|
||||||
|
"preact": "^10.29.3",
|
||||||
"qrcode-generator": "^2.0.4",
|
"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'],
|
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||||
['es', 'es.ts', 'es', 'Spanish'],
|
['es', 'es.ts', 'es', 'Spanish'],
|
||||||
|
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||||
|
['de', 'de.ts', 'de', 'German'],
|
||||||
|
['fr', 'fr.ts', 'fr', 'French'],
|
||||||
|
['it', 'it.ts', 'it', 'Italian'],
|
||||||
|
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function readLocale(fileName, variableName) {
|
function readLocale(fileName, variableName) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const scratch = process.env.SCRATCH || '.';
|
||||||
|
const cases = [
|
||||||
|
'http://127.0.0.1',
|
||||||
|
'http://169.254.169.254',
|
||||||
|
'http://[::1]',
|
||||||
|
'http://[0:0:0:0:0:0:0:1]',
|
||||||
|
'http://[::2]',
|
||||||
|
'http://[::]',
|
||||||
|
'http://[fe80::1]',
|
||||||
|
'http://[fc00::1]',
|
||||||
|
'https://example.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const url of cases) {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
|
||||||
|
out.push({ url, allowed: true, normalized });
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
|
||||||
|
fs.writeFileSync(path, JSON.stringify(out, null, 2));
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
|
||||||
|
// Security expectation: IPv6 loopback must NOT be allowed.
|
||||||
|
const loopback = out.find((row) => row.url === 'http://[::1]');
|
||||||
|
if (loopback?.allowed) {
|
||||||
|
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
|
||||||
|
process.exitCode = 2;
|
||||||
|
} else {
|
||||||
|
console.log('IPv6 loopback rejected as expected');
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.7.0';
|
export const APP_VERSION = '1.7.4';
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
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_INTERVAL_HOURS = 24;
|
||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-6
@@ -3,12 +3,14 @@
|
|||||||
// Access token lifetime in seconds.
|
// Access token lifetime in seconds.
|
||||||
// 访问令牌有效期(秒)。
|
// 访问令牌有效期(秒)。
|
||||||
accessTokenTtlSeconds: 7200,
|
accessTokenTtlSeconds: 7200,
|
||||||
// Refresh token lifetime in milliseconds.
|
// Refresh sessions use a reusable opaque token with a sliding idle lifetime.
|
||||||
// 刷新令牌有效期(毫秒)。
|
// 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限。
|
||||||
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
|
refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
// Grace window for previous refresh token after rotation (ms).
|
refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
|
||||||
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
|
// Hard upper bound for one login session, regardless of sliding refreshes.
|
||||||
|
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
|
||||||
|
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
|
||||||
// Refresh token random byte length.
|
// Refresh token random byte length.
|
||||||
// 刷新令牌随机字节长度。
|
// 刷新令牌随机字节长度。
|
||||||
refreshTokenRandomBytes: 32,
|
refreshTokenRandomBytes: 32,
|
||||||
@@ -62,6 +64,12 @@
|
|||||||
// Refresh-token grant budget per IP per minute.
|
// Refresh-token grant budget per IP per minute.
|
||||||
// refresh_token 授权每 IP 每分钟请求配额。
|
// refresh_token 授权每 IP 每分钟请求配额。
|
||||||
refreshTokenRequestsPerMinute: 30,
|
refreshTokenRequestsPerMinute: 30,
|
||||||
|
// Coarser IP budget; the per-session budget above remains the primary guard.
|
||||||
|
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
|
||||||
|
refreshTokenRequestsPerIpMinute: 300,
|
||||||
|
// Passwordless/auth-request creation budget per IP/email/device per minute.
|
||||||
|
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
|
||||||
|
authRequestRequestsPerMinute: 5,
|
||||||
// Fixed window size for API rate limiting in seconds.
|
// Fixed window size for API rate limiting in seconds.
|
||||||
// API 限流固定窗口大小(秒)。
|
// API 限流固定窗口大小(秒)。
|
||||||
apiWindowSeconds: 60,
|
apiWindowSeconds: 60,
|
||||||
@@ -156,3 +164,10 @@
|
|||||||
cipherKeyEncryptionFeatureEnabled: true,
|
cipherKeyEncryptionFeatureEnabled: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
|
||||||
|
const normalized = String(clientType || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
|
||||||
|
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
|
||||||
|
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
executeConfiguredBackup,
|
executeConfiguredBackup,
|
||||||
importAndAuditRemoteBackupFile,
|
importAndAuditRemoteBackupFile,
|
||||||
} from '../handlers/backup';
|
} from '../handlers/backup';
|
||||||
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||||
import { zipSync } from 'fflate';
|
import { zipSync } from 'fflate';
|
||||||
|
|
||||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||||
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||||
try {
|
try {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
const storage = new StorageService(this.env.DB);
|
const storage = new StorageService(this.env.DB);
|
||||||
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
|
|||||||
scanStartMs = now.getTime();
|
scanStartMs = now.getTime();
|
||||||
for (const destination of dueDestinations) {
|
for (const destination of dueDestinations) {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
|
try {
|
||||||
await executeConfiguredBackup(
|
await executeConfiguredBackup(
|
||||||
this.env,
|
this.env,
|
||||||
storage,
|
storage,
|
||||||
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
|
|||||||
() => this.touchJob(token)
|
() => this.touchJob(token)
|
||||||
);
|
);
|
||||||
completed += 1;
|
completed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
destinationId: destination.id,
|
||||||
|
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
completed,
|
completed,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
!checksumOk,
|
!checksumOk,
|
||||||
body.auditMetadata || null,
|
body.auditMetadata || null,
|
||||||
targetDeviceIdentifier
|
targetDeviceIdentifier,
|
||||||
|
() => this.touchJob(token)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(result.result), {
|
return new Response(JSON.stringify(result.result), {
|
||||||
@@ -361,7 +372,7 @@ export class BackupTransferRunner {
|
|||||||
return badRequest('Remote attachment download payload is invalid');
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
}
|
}
|
||||||
const blobName = String(body?.blobName || '').trim();
|
const blobName = String(body?.blobName || '').trim();
|
||||||
if (!body?.destination || !blobName) {
|
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
return badRequest('Remote attachment download payload is invalid');
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
}
|
}
|
||||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||||
@@ -387,7 +398,7 @@ export class BackupTransferRunner {
|
|||||||
const blobNames = Array.from(new Set(
|
const blobNames = Array.from(new Set(
|
||||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||||
.map((blobName) => String(blobName || '').trim())
|
.map((blobName) => String(blobName || '').trim())
|
||||||
.filter(Boolean)
|
.filter(isSafeBackupAttachmentBlobName)
|
||||||
));
|
));
|
||||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||||
return badRequest('Remote attachment batch download payload is invalid');
|
return badRequest('Remote attachment batch download payload is invalid');
|
||||||
@@ -435,7 +446,7 @@ export class BackupTransferRunner {
|
|||||||
|
|
||||||
for (const attachment of body.attachments) {
|
for (const attachment of body.attachments) {
|
||||||
const blobName = String(attachment?.blobName || '').trim();
|
const blobName = String(attachment?.blobName || '').trim();
|
||||||
if (!blobName) {
|
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
return badRequest('Attachment chunk payload is invalid');
|
return badRequest('Attachment chunk payload is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { bytesToBase64Url } from '../utils/passkey';
|
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||||
import {
|
import {
|
||||||
accountPasskeyCredentialToResponse,
|
accountPasskeyCredentialToResponse,
|
||||||
accountPasskeyPrfStatus,
|
accountPasskeyPrfStatus,
|
||||||
@@ -29,8 +29,10 @@ import {
|
|||||||
verifyAccountPasskeyToken,
|
verifyAccountPasskeyToken,
|
||||||
} from '../utils/account-passkeys';
|
} from '../utils/account-passkeys';
|
||||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
|
import { createRecoveryCode } from '../utils/recovery-code';
|
||||||
|
|
||||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||||
|
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||||
|
|
||||||
function parseBodyObject(body: unknown): Record<string, any> {
|
function parseBodyObject(body: unknown): Record<string, any> {
|
||||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||||
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
|||||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
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>): {
|
function readPrfKeySet(body: Record<string, any>): {
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
|
|||||||
if (payload.userId && credential.userId !== payload.userId) {
|
if (payload.userId && credential.userId !== payload.userId) {
|
||||||
throw new Error('Passkey does not belong to this user');
|
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 userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||||
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
user: User
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
if (!credentials.length) return null;
|
||||||
|
|
||||||
|
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
allowCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
userVerification: 'discouraged',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||||
|
return options as unknown as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertTwoFactorPasskeyCredential(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
user: User,
|
||||||
|
deviceResponse: unknown
|
||||||
|
): Promise<AccountPasskeyCredential> {
|
||||||
|
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Invalid passkey assertion response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||||
|
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||||
|
throw new Error('Passkey is not registered for two-step login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = readAuthenticationChallenge(response);
|
||||||
|
if (!challenge) {
|
||||||
|
throw new Error('Passkey assertion challenge is missing');
|
||||||
|
}
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||||
|
await sha256Base64Url(challenge),
|
||||||
|
'TwoFactorAuthentication',
|
||||||
|
user.id,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (!consumed) {
|
||||||
|
throw new Error('Passkey challenge has expired or was already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
credential: toSimpleWebAuthnCredential(credential),
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
if (!verification.verified) {
|
||||||
|
throw new Error('Passkey assertion could not be verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateAccountPasskeyCounter(
|
||||||
|
credential.userId,
|
||||||
|
credential.credentialId,
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
credential.counter = verification.authenticationInfo.newCounter;
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
rpName,
|
||||||
|
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name || user.email,
|
||||||
|
attestationType: 'none',
|
||||||
|
timeout: 60000,
|
||||||
|
excludeCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'discouraged',
|
||||||
|
requireResidentKey: false,
|
||||||
|
userVerification: 'discouraged',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||||
|
return jsonResponse(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||||
|
if (!registrationResponse) {
|
||||||
|
return errorResponse('Invalid passkey registration response', 400);
|
||||||
|
}
|
||||||
|
const challenge = readRegistrationChallenge(registrationResponse);
|
||||||
|
if (!challenge) {
|
||||||
|
return errorResponse('Passkey challenge is missing', 400);
|
||||||
|
}
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||||
|
await sha256Base64Url(challenge),
|
||||||
|
'TwoFactorCreate',
|
||||||
|
userId,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (!consumed) {
|
||||||
|
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: registrationResponse,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
requireUserPresence: true,
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
if (!verification.verified) {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||||
|
if (existing) {
|
||||||
|
return errorResponse('Passkey is already registered', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||||
|
await storage.saveAccountPasskeyCredential({
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
purpose: 'twoFactor',
|
||||||
|
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||||
|
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||||
|
credentialId: verification.registrationInfo.credential.id,
|
||||||
|
counter: verification.registrationInfo.credential.counter,
|
||||||
|
type: verification.registrationInfo.credentialType || 'public-key',
|
||||||
|
aaGuid: verification.registrationInfo.aaguid || null,
|
||||||
|
transports,
|
||||||
|
encryptedUserKey: null,
|
||||||
|
encryptedPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
supportsPrf: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.updatedAt = now;
|
||||||
|
await storage.saveUser(user);
|
||||||
|
}
|
||||||
|
await storage.deleteRefreshTokensByUserId(userId);
|
||||||
|
AuthService.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.webauthn_2fa.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: null,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedId = Number(body.id ?? body.Id);
|
||||||
|
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||||
|
return errorResponse('Invalid key id', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (credentials.length < 2) {
|
||||||
|
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
}
|
||||||
|
const credential = credentials[requestedId - 1];
|
||||||
|
if (!credential) {
|
||||||
|
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||||
|
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
await storage.deleteRefreshTokensByUserId(userId);
|
||||||
|
AuthService.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.webauthn_2fa.delete',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: credential.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
const body = await readJsonBody(request);
|
const body = await readJsonBody(request);
|
||||||
if (!body) return errorResponse('Invalid request payload', 400);
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
|
|||||||
const credential: AccountPasskeyCredential = {
|
const credential: AccountPasskeyCredential = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
userId,
|
userId,
|
||||||
|
purpose: 'login',
|
||||||
name: normalizeAccountPasskeyName(body.name),
|
name: normalizeAccountPasskeyName(body.name),
|
||||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||||
credentialId: verification.registrationInfo.credential.id,
|
credentialId: verification.registrationInfo.credential.id,
|
||||||
|
|||||||
+388
-69
@@ -1,4 +1,4 @@
|
|||||||
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, User } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
@@ -6,14 +6,20 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
|
|||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { hashApiKey } from '../utils/api-key';
|
||||||
|
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
import { buildProfileResponse } from '../utils/profile-response';
|
import { buildProfileResponse } from '../utils/profile-response';
|
||||||
|
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||||
|
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||||
|
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||||
|
|
||||||
// CONTRACT:
|
// CONTRACT:
|
||||||
// users.master_password_hash is server-side login verification only. It does
|
// users.master_password_hash is server-side login verification only. It does
|
||||||
@@ -36,6 +42,9 @@ function looksLikeEncString(value: string): boolean {
|
|||||||
*/
|
*/
|
||||||
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
||||||
const type = kdfType ?? 0;
|
const type = kdfType ?? 0;
|
||||||
|
if (type !== 0 && type !== 1) {
|
||||||
|
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
|
||||||
|
}
|
||||||
if (type === 0) {
|
if (type === 0) {
|
||||||
// PBKDF2-SHA256: minimum 100 000 iterations
|
// PBKDF2-SHA256: minimum 100 000 iterations
|
||||||
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
||||||
@@ -149,10 +158,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
|
|||||||
return normalized ? normalized : null;
|
return normalized ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
if (!secret) return 'missing';
|
if (!secret) return 'missing';
|
||||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
|
||||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -193,6 +201,31 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
|
|||||||
return typeof current === 'number' ? current : undefined;
|
return typeof current === 'number' ? current : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||||
|
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
|
||||||
|
if (!clientId) return null;
|
||||||
|
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
|
||||||
|
return { clientId, secretKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStoredYubicoCredentials(
|
||||||
|
storage: StorageService,
|
||||||
|
env: Env,
|
||||||
|
email: string,
|
||||||
|
otp: string
|
||||||
|
): Promise<YubicoApiCredentials | null> {
|
||||||
|
const existing = await getStoredYubicoCredentials(storage, env);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const credentials = await requestYubicoApiCredentials(email, otp);
|
||||||
|
if (!credentials) return null;
|
||||||
|
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||||
|
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
@@ -241,8 +274,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
if (unsafe) {
|
if (unsafe) {
|
||||||
const message = unsafe === 'missing'
|
const message = unsafe === 'missing'
|
||||||
? 'JWT_SECRET is not set'
|
? 'JWT_SECRET is not set'
|
||||||
: unsafe === 'default'
|
|
||||||
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
|
||||||
: 'JWT_SECRET must be at least 32 characters';
|
: 'JWT_SECRET must be at least 32 characters';
|
||||||
return errorResponse(message, 400);
|
return errorResponse(message, 400);
|
||||||
}
|
}
|
||||||
@@ -321,9 +352,15 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
verifyDevices: true,
|
verifyDevices: false, // new-device verification requires email delivery (not available)
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
|
yubikeyKey1: null,
|
||||||
|
yubikeyKey2: null,
|
||||||
|
yubikeyKey3: null,
|
||||||
|
yubikeyKey4: null,
|
||||||
|
yubikeyKey5: null,
|
||||||
|
yubikeyNfc: false,
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -353,20 +390,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return errorResponse('Invite code is required', 403);
|
return errorResponse('Invite code is required', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
||||||
|
if (!inviteMarked) {
|
||||||
|
return errorResponse('Invite code is invalid or expired', 403);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.createUser(user);
|
await storage.createUser(user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await storage.revertInviteUsed(inviteCode, user.id);
|
||||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
if (msg.includes('unique') || msg.includes('constraint')) {
|
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||||
return errorResponse('Email already registered', 409);
|
return errorResponse('Email already registered', 409);
|
||||||
}
|
}
|
||||||
|
console.error('Registration failed after invite reservation:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
try {
|
||||||
if (!inviteMarked) {
|
const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
|
||||||
await storage.deleteUserById(user.id);
|
if (!assigned) {
|
||||||
return errorResponse('Invite code is invalid or expired', 403);
|
console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// The invite is already consumed. Do not reactivate it after the user row exists.
|
||||||
|
console.error('Invite used_by assignment failed after registration:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
@@ -403,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
|
||||||
`${clientIdentifier}:password-hint`,
|
`${clientIdentifier}:password-hint`,
|
||||||
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||||
60
|
60
|
||||||
@@ -425,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
|
||||||
`${clientIdentifier}:password-hint-hour`,
|
`${clientIdentifier}:password-hint-hour`,
|
||||||
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||||
60 * 60
|
60 * 60
|
||||||
@@ -505,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT/POST /api/accounts/verify-devices
|
// PUT/POST /api/accounts/verify-devices
|
||||||
|
// New-device verification requires an email delivery channel which NodeWarden
|
||||||
|
// does not provide. This endpoint always rejects the request so clients receive
|
||||||
|
// clear feedback that the feature is unavailable rather than silently ignoring
|
||||||
|
// the user's preference.
|
||||||
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: {
|
// Log the attempt for audit purposes, but do not change state.
|
||||||
secret?: string;
|
|
||||||
masterPasswordHash?: string;
|
|
||||||
verifyDevices?: boolean;
|
|
||||||
VerifyDevices?: boolean;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return errorResponse('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
|
|
||||||
if (typeof verifyDevices !== 'boolean') {
|
|
||||||
return errorResponse('verifyDevices must be true or false', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
|
|
||||||
if (!verified) {
|
|
||||||
return errorResponse('User verification failed.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
user.verifyDevices = verifyDevices;
|
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
await storage.saveUser(user);
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'account.verify_devices.update',
|
action: 'account.verify_devices.update.rejected',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'info',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
verifyDevices: user.verifyDevices,
|
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||||
...auditRequestMetadata(request),
|
...auditRequestMetadata(request),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/accounts/keys
|
// GET /api/accounts/keys
|
||||||
@@ -689,6 +717,11 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
|
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
|
||||||
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
||||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||||
|
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
|
||||||
|
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
|
||||||
|
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
|
||||||
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||||
if (nextKey) user.key = nextKey;
|
if (nextKey) user.key = nextKey;
|
||||||
@@ -698,8 +731,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
|||||||
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
|
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
|
||||||
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
||||||
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
||||||
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
|
if (shouldUpdateHint) {
|
||||||
user.masterPasswordHint = body.masterPasswordHint;
|
user.masterPasswordHint = nextMasterPasswordHint ?? null;
|
||||||
}
|
}
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
@@ -753,6 +786,44 @@ function twoFactorAuthenticatorResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function yubiKeyResponse(user: User): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Enabled: isYubiKeyEnabled(user),
|
||||||
|
Key1: user.yubikeyKey1,
|
||||||
|
Key2: user.yubikeyKey2,
|
||||||
|
Key3: user.yubikeyKey3,
|
||||||
|
Key4: user.yubikeyKey4,
|
||||||
|
Key5: user.yubikeyKey5,
|
||||||
|
Nfc: !!user.yubikeyNfc,
|
||||||
|
Object: 'twoFactorYubiKey',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// New-device verification requires an email delivery channel to send OTP
|
||||||
|
// challenges to unknown devices. NodeWarden does not integrate with an email
|
||||||
|
// provider, so this feature is intentionally unavailable. The settings
|
||||||
|
// response always reports disabled regardless of any legacy DB value.
|
||||||
|
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Enabled: false,
|
||||||
|
enabled: false,
|
||||||
|
VerifyDevices: false,
|
||||||
|
verifyDevices: false,
|
||||||
|
Object: 'deviceVerificationSettings',
|
||||||
|
object: 'deviceVerificationSettings',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||||
|
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||||
|
return {
|
||||||
|
...yubiKeyResponse(user),
|
||||||
|
YubicoConfigured: !!credentials?.clientId,
|
||||||
|
YubicoClientId: credentials?.clientId ?? '',
|
||||||
|
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/two-factor
|
// GET /api/two-factor
|
||||||
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
@@ -760,9 +831,11 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
|||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
const data = user.totpSecret
|
const data = [];
|
||||||
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
|
if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||||
: [];
|
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
|
||||||
|
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -794,6 +867,77 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
|
|||||||
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/two-factor/get-yubikey
|
||||||
|
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/two-factor/get-device-verification-settings
|
||||||
|
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/two-factor/device-verification-settings
|
||||||
|
// New-device verification is not supported (no email delivery channel).
|
||||||
|
// Reject any attempt to enable it; always return disabled state.
|
||||||
|
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
|
||||||
|
|
||||||
|
// Log the attempt for audit purposes — never change state.
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.verify_devices.update.rejected',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: {
|
||||||
|
requested: rawEnabled,
|
||||||
|
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||||
|
source: 'two-factor.device-verification-settings',
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rawEnabled === true) {
|
||||||
|
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting to false is the only supported state — return it.
|
||||||
|
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||||
|
}
|
||||||
|
|
||||||
// PUT/POST /api/two-factor/authenticator
|
// PUT/POST /api/two-factor/authenticator
|
||||||
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -817,7 +961,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
|||||||
return errorResponse('User verification failed.', 400);
|
return errorResponse('User verification failed.', 400);
|
||||||
}
|
}
|
||||||
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
|
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
|
||||||
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
|
const matchedCounter = await findMatchingTotpCounter(key, token);
|
||||||
|
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||||
|
return errorResponse('Invalid token.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
user.totpSecret = key;
|
user.totpSecret = key;
|
||||||
if (!user.totpRecoveryCode) {
|
if (!user.totpRecoveryCode) {
|
||||||
@@ -840,6 +987,141 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
|||||||
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/two-factor/yubikey
|
||||||
|
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
readBodyString(body, ['key1', 'Key1']),
|
||||||
|
readBodyString(body, ['key2', 'Key2']),
|
||||||
|
readBodyString(body, ['key3', 'Key3']),
|
||||||
|
readBodyString(body, ['key4', 'Key4']),
|
||||||
|
readBodyString(body, ['key5', 'Key5']),
|
||||||
|
];
|
||||||
|
const publicIds: Array<string | null> = [];
|
||||||
|
let credentials = await getStoredYubicoCredentials(storage, env);
|
||||||
|
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||||
|
for (const key of keys) {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
publicIds.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const publicId = yubiKeyPublicIdFromOtp(trimmed);
|
||||||
|
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||||
|
if (isYubiKeyPublicId(trimmed)) {
|
||||||
|
publicIds.push(publicId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!credentials) {
|
||||||
|
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
|
||||||
|
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||||
|
apiKeyBootstrapOtpIndex = publicIds.length;
|
||||||
|
}
|
||||||
|
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||||
|
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||||
|
}
|
||||||
|
publicIds.push(publicId);
|
||||||
|
}
|
||||||
|
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
|
||||||
|
|
||||||
|
user.yubikeyKey1 = publicIds[0] ?? null;
|
||||||
|
user.yubikeyKey2 = publicIds[1] ?? null;
|
||||||
|
user.yubikeyKey3 = publicIds[2] ?? null;
|
||||||
|
user.yubikeyKey4 = publicIds[3] ?? null;
|
||||||
|
user.yubikeyKey5 = publicIds[4] ?? null;
|
||||||
|
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
|
||||||
|
if (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.yubikey.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/two-factor/yubikey/config
|
||||||
|
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
|
||||||
|
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
|
||||||
|
if (!clientId) return errorResponse('Yubico Client ID is required.', 400);
|
||||||
|
|
||||||
|
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
|
||||||
|
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
|
||||||
|
|
||||||
|
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/two-factor/yubikey/bootstrap
|
||||||
|
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
|
||||||
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
|
||||||
|
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||||
|
const credentials = await requestYubicoApiCredentials(user.email, otp);
|
||||||
|
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||||
|
|
||||||
|
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||||
|
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||||
|
|
||||||
|
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
||||||
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -856,30 +1138,40 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
|
|
||||||
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||||
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
||||||
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
|
||||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
|
||||||
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
|
||||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
let verified = false;
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
if (key && userVerificationToken) {
|
|
||||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
|
||||||
}
|
|
||||||
if (!verified) {
|
|
||||||
verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
}
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
user.totpSecret = null;
|
user.totpSecret = null;
|
||||||
|
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
|
||||||
|
user.yubikeyKey1 = null;
|
||||||
|
user.yubikeyKey2 = null;
|
||||||
|
user.yubikeyKey3 = null;
|
||||||
|
user.yubikeyKey4 = null;
|
||||||
|
user.yubikeyKey5 = null;
|
||||||
|
user.yubikeyNfc = false;
|
||||||
|
} else {
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
for (const credential of credentials) {
|
||||||
|
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||||
|
}
|
||||||
|
}
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
AuthService.invalidateUserCache(user.id);
|
AuthService.invalidateUserCache(user.id);
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'account.totp.disable',
|
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||||
|
? 'account.totp.disable'
|
||||||
|
: type === TWO_FACTOR_PROVIDER_YUBIKEY
|
||||||
|
? 'account.yubikey.disable'
|
||||||
|
: 'account.webauthn_2fa.disable',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'security',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
@@ -887,11 +1179,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
metadata: auditRequestMetadata(request),
|
metadata: auditRequestMetadata(request),
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
|
return jsonResponse(twoFactorProviderResponse(type, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/accounts/totp
|
// PUT /api/accounts/totp
|
||||||
// enable: { enabled: true, secret: "...", token: "123456" }
|
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
|
||||||
// disable: { enabled: false, masterPasswordHash: "..." }
|
// disable: { enabled: false, masterPasswordHash: "..." }
|
||||||
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
@@ -899,7 +1191,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
|
let body: {
|
||||||
|
enabled?: boolean;
|
||||||
|
secret?: string;
|
||||||
|
token?: string;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
userVerificationToken?: string;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -908,14 +1206,26 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
if (body.enabled === true) {
|
if (body.enabled === true) {
|
||||||
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
||||||
|
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
|
||||||
|
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
||||||
if (!isTotpEnabled(normalizedSecret)) {
|
if (!isTotpEnabled(normalizedSecret)) {
|
||||||
return errorResponse('Invalid TOTP secret', 400);
|
return errorResponse('Invalid TOTP secret', 400);
|
||||||
}
|
}
|
||||||
if (!body.token) {
|
if (!body.token) {
|
||||||
return errorResponse('TOTP token is required', 400);
|
return errorResponse('TOTP token is required', 400);
|
||||||
}
|
}
|
||||||
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
let verifiedUser = false;
|
||||||
if (!verified) {
|
if (userVerificationToken) {
|
||||||
|
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
|
||||||
|
}
|
||||||
|
if (!verifiedUser && masterPasswordHash) {
|
||||||
|
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
|
if (!verifiedUser) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
|
||||||
|
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||||
return errorResponse('Invalid TOTP token', 400);
|
return errorResponse('Invalid TOTP token', 400);
|
||||||
}
|
}
|
||||||
user.totpSecret = normalizedSecret;
|
user.totpSecret = normalizedSecret;
|
||||||
@@ -1063,6 +1373,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.totpSecret = null;
|
user.totpSecret = null;
|
||||||
|
user.yubikeyKey1 = null;
|
||||||
|
user.yubikeyKey2 = null;
|
||||||
|
user.yubikeyKey3 = null;
|
||||||
|
user.yubikeyKey4 = null;
|
||||||
|
user.yubikeyKey5 = null;
|
||||||
|
user.yubikeyNfc = false;
|
||||||
|
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
for (const credential of webAuthnCredentials) {
|
||||||
|
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||||
|
}
|
||||||
user.totpRecoveryCode = createRecoveryCode();
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
@@ -1165,9 +1485,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
|||||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||||
if (!valid) return errorResponse('Invalid password', 400);
|
if (!valid) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
if (rotate || user.apiKey === null) {
|
// Only the fresh secret is returned once; the database stores a hash.
|
||||||
// Upstream apikeys are 30-character random alphanumeric strings
|
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
user.apiKey = await hashApiKey(plainApiKey);
|
||||||
if (rotate) {
|
if (rotate) {
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
@@ -1184,10 +1504,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
|||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: auditRequestMetadata(request),
|
metadata: auditRequestMetadata(request),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
apiKey: user.apiKey,
|
apiKey: plainApiKey,
|
||||||
revisionDate: user.updatedAt,
|
revisionDate: user.updatedAt,
|
||||||
object: 'apiKey',
|
object: 'apiKey',
|
||||||
});
|
});
|
||||||
|
|||||||
+75
-24
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
|
|||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requireMasterPasswordHash(
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
masterPasswordHash: unknown
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const normalized = String(masterPasswordHash || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||||
|
if (!valid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
return body && typeof body === 'object' && !Array.isArray(body)
|
||||||
|
? body as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function randomHex(bytes: number): string {
|
function randomHex(bytes: number): string {
|
||||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||||
@@ -69,18 +97,22 @@ export async function handleAdminListUsers(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
return jsonResponse({
|
const data = await Promise.all(users.map(async user => {
|
||||||
data: users.map(user => ({
|
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
|
||||||
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
revisionDate: user.updatedAt,
|
revisionDate: user.updatedAt,
|
||||||
object: 'user',
|
object: 'user',
|
||||||
})),
|
};
|
||||||
|
}));
|
||||||
|
return jsonResponse({
|
||||||
|
data,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
});
|
});
|
||||||
@@ -183,6 +215,9 @@ export async function handleAdminClearAuditLogs(
|
|||||||
}
|
}
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const deleted = await storage.clearAuditLogs();
|
const deleted = await storage.clearAuditLogs();
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||||
|
deleted,
|
||||||
|
}, request);
|
||||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,14 +232,11 @@ export async function handleAdminCreateInvite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let body: { expiresInHours?: number } = {};
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
body = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresInHours = Number.isFinite(body.expiresInHours)
|
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
|
||||||
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||||
: 24 * 7;
|
: 24 * 7;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -249,7 +281,7 @@ export async function handleAdminListInvites(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/invites/:code
|
// DELETE /api/admin/invites/:code
|
||||||
export async function handleAdminRevokeInvite(
|
export async function handleAdminDeleteInvite(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
actorUser: User,
|
actorUser: User,
|
||||||
@@ -259,13 +291,19 @@ export async function handleAdminRevokeInvite(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revoked = await storage.revokeInvite(code);
|
const deleted = await storage.deleteInvite(code);
|
||||||
if (!revoked) {
|
if (!deleted) {
|
||||||
return errorResponse('Invite not found or already inactive', 404);
|
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 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +313,25 @@ export async function handleAdminDeleteAllInvites(
|
|||||||
env: Env,
|
env: Env,
|
||||||
actorUser: User
|
actorUser: User
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
|
||||||
if (!isAdmin(actorUser)) {
|
if (!isAdmin(actorUser)) {
|
||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
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();
|
const deleted = await storage.deleteAllInvites();
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||||
deleted,
|
deleted,
|
||||||
@@ -300,12 +351,9 @@ export async function handleAdminSetUserStatus(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { status?: string };
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
return errorResponse('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||||
if (!nextStatus) {
|
if (!nextStatus) {
|
||||||
@@ -348,7 +396,6 @@ export async function handleAdminDeleteUser(
|
|||||||
actorUser: User,
|
actorUser: User,
|
||||||
targetUserId: string
|
targetUserId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
|
||||||
if (!isAdmin(actorUser)) {
|
if (!isAdmin(actorUser)) {
|
||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
@@ -356,6 +403,10 @@ export async function handleAdminDeleteUser(
|
|||||||
return errorResponse('You cannot delete yourself', 400);
|
return errorResponse('You cannot delete yourself', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
|
if (passwordError) return passwordError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const target = await storage.getUserById(targetUserId);
|
const target = await storage.getUserById(targetUserId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
+30
-28
@@ -1,9 +1,10 @@
|
|||||||
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Attachment, Cipher } from '../types';
|
||||||
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||||
import {
|
import {
|
||||||
createAttachmentUploadToken,
|
createAttachmentUploadToken,
|
||||||
createFileDownloadToken,
|
createFileDownloadToken,
|
||||||
@@ -123,6 +124,10 @@ async function processAttachmentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
|
if (await getBlobObject(env, path)) {
|
||||||
|
return errorResponse('Attachment file has already been uploaded', 409);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await putBlobObject(env, path, upload.body, {
|
await putBlobObject(env, path, upload.body, {
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
@@ -166,7 +171,7 @@ export async function handleCreateAttachment(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// 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) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
@@ -204,7 +209,7 @@ export async function handleCreateAttachment(
|
|||||||
await storage.saveAttachment(attachment);
|
await storage.saveAttachment(attachment);
|
||||||
|
|
||||||
// Add attachment to cipher
|
// Add attachment to cipher
|
||||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
@@ -214,7 +219,7 @@ export async function handleCreateAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// 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 attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
const jwtSecret = getSafeJwtSecret(env);
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
@@ -243,13 +248,13 @@ export async function handleUploadAttachment(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// 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) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
@@ -282,12 +287,12 @@ export async function handlePublicUploadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
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) {
|
if (!cipher || cipher.userId !== claims.userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
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) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
@@ -307,13 +312,13 @@ export async function handleGetAttachment(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// 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) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
@@ -348,12 +353,12 @@ export async function handleUpdateAttachmentMetadata(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
@@ -404,10 +409,8 @@ export async function handlePublicDownloadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = getSafeJwtSecret(env);
|
||||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
if (!secret) return errorResponse('Server configuration error', 500);
|
||||||
return errorResponse('Server configuration error', 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
@@ -417,7 +420,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify token
|
// Verify token
|
||||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
const claims = await verifyFileDownloadToken(token, secret);
|
||||||
if (!claims) {
|
if (!claims) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
@@ -436,20 +439,19 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const object = await getBlobObject(env, path);
|
|
||||||
|
|
||||||
if (!object) {
|
|
||||||
return errorResponse('Attachment file not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(env, path);
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
@@ -470,13 +472,13 @@ export async function handleDeleteAttachment(
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// 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) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
@@ -485,7 +487,7 @@ export async function handleDeleteAttachment(
|
|||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
|
|
||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachmentForUser(attachmentId, userId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
@@ -500,7 +502,7 @@ export async function handleDeleteAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// 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 attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
|
|||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
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_AUTHENTICATE_AND_UNLOCK = 0;
|
||||||
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||||
@@ -14,6 +16,19 @@ function normalizeText(value: unknown, maxLength: number): string {
|
|||||||
return String(value ?? '').trim().slice(0, maxLength);
|
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 {
|
function getClientIp(request: Request): string | null {
|
||||||
return (
|
return (
|
||||||
request.headers.get('CF-Connecting-IP') ||
|
request.headers.get('CF-Connecting-IP') ||
|
||||||
@@ -81,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
|
|||||||
RequestCountryName: authRequest.requestCountryName,
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
key: authRequest.key,
|
key: authRequest.key,
|
||||||
Key: authRequest.key,
|
Key: authRequest.key,
|
||||||
masterPasswordHash: authRequest.masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
MasterPasswordHash: authRequest.masterPasswordHash,
|
MasterPasswordHash: null,
|
||||||
creationDate: authRequest.creationDate,
|
creationDate: authRequest.creationDate,
|
||||||
CreationDate: authRequest.creationDate,
|
CreationDate: authRequest.creationDate,
|
||||||
responseDate: authRequest.responseDate,
|
responseDate: authRequest.responseDate,
|
||||||
@@ -118,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enforceAuthRequestCreateRateLimit(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
email: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
|
||||||
|
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
|
||||||
|
const encodedEmail = encodeURIComponent(email || 'missing');
|
||||||
|
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
|
||||||
|
const budgets = await Promise.all([
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
|
||||||
|
]);
|
||||||
|
const blocked = budgets.find((budget) => !budget.allowed);
|
||||||
|
if (!blocked) return null;
|
||||||
|
|
||||||
|
return errorResponse('Too many authentication requests. Try again later.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
if (body[name] !== undefined) return body[name];
|
if (body[name] !== undefined) return body[name];
|
||||||
@@ -151,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
|
|||||||
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||||
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
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) {
|
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||||
return errorResponse('Invalid auth request type.', 400);
|
return errorResponse('Invalid auth request type.', 400);
|
||||||
}
|
}
|
||||||
@@ -186,9 +227,75 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
|
|||||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
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> {
|
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const authRequest = await storage.getAuthRequestById(id);
|
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
}
|
}
|
||||||
@@ -226,7 +333,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
const body = await readJsonBody(request);
|
const body = await readJsonBody(request);
|
||||||
if (!body) return errorResponse('Invalid request payload', 400);
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
const authRequest = await storage.getAuthRequestById(id);
|
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||||
return errorResponse('Not found', 404);
|
return errorResponse('Not found', 404);
|
||||||
}
|
}
|
||||||
@@ -242,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
|
||||||
const responseDeviceIdentifier =
|
const responseDeviceIdentifier =
|
||||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
readActingDeviceIdentifier(request) ||
|
readActingDeviceIdentifier(request) ||
|
||||||
@@ -251,15 +357,18 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
if (approved && !key) {
|
if (approved && !key) {
|
||||||
return errorResponse('Encrypted key is required to approve the request.', 400);
|
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, {
|
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||||
approved,
|
approved,
|
||||||
responseDeviceIdentifier,
|
responseDeviceIdentifier,
|
||||||
key,
|
key,
|
||||||
masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
});
|
});
|
||||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
const updatedRequest = await storage.getAuthRequestById(id);
|
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||||
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||||
if (approved) {
|
if (approved) {
|
||||||
|
|||||||
+231
-54
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
|
|||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import {
|
import {
|
||||||
type BackupArchiveBundle,
|
type BackupArchiveBundle,
|
||||||
|
MAX_BACKUP_ARCHIVE_BYTES,
|
||||||
buildBackupArchive,
|
buildBackupArchive,
|
||||||
inspectBackupArchiveFileNameChecksum,
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
isSafeBackupAttachmentBlobName,
|
||||||
parseBackupArchive,
|
parseBackupArchive,
|
||||||
verifyBackupArchiveFileNameChecksum,
|
verifyBackupArchiveFileNameChecksum,
|
||||||
} from '../services/backup-archive';
|
} from '../services/backup-archive';
|
||||||
@@ -18,9 +20,11 @@ import {
|
|||||||
loadBackupSettings,
|
loadBackupSettings,
|
||||||
normalizeBackupSettingsInput,
|
normalizeBackupSettingsInput,
|
||||||
normalizeImportedBackupSettings,
|
normalizeImportedBackupSettings,
|
||||||
|
redactBackupSettingsSecrets,
|
||||||
repairBackupSettings,
|
repairBackupSettings,
|
||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
|
updateBackupDestinationRuntime,
|
||||||
} from '../services/backup-config';
|
} from '../services/backup-config';
|
||||||
import {
|
import {
|
||||||
type BackupImportExecutionResult,
|
type BackupImportExecutionResult,
|
||||||
@@ -40,15 +44,60 @@ import {
|
|||||||
uploadBackupArchive,
|
uploadBackupArchive,
|
||||||
} from '../services/backup-uploader';
|
} from '../services/backup-uploader';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
|
||||||
|
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
import { unzipSync } from 'fflate';
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRequestContentLength(request: Request): number | null {
|
||||||
|
const raw = request.headers.get('content-length');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return null;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
|
||||||
|
const normalized = String(masterPasswordHash || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
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 requireBackupRepairVerification(
|
||||||
|
actorUser: User,
|
||||||
|
body: { masterPasswordHash?: string; userVerificationToken?: string },
|
||||||
|
env: Env
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
|
||||||
|
if (masterPasswordHash) {
|
||||||
|
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userVerificationToken = String(body.userVerificationToken || '').trim();
|
||||||
|
if (!userVerificationToken) {
|
||||||
|
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
|
||||||
|
}
|
||||||
|
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
|
||||||
|
if (!valid) {
|
||||||
|
return errorResponse('Invalid user verification token', 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function writeAuditLog(
|
async function writeAuditLog(
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
@@ -92,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error('Backup attachment blob is required');
|
throw new Error('Backup attachment blob is required');
|
||||||
}
|
}
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
if (!isSafeBackupAttachmentBlobName(normalized)) {
|
||||||
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
|
|
||||||
throw new Error('Backup attachment blob is invalid');
|
throw new Error('Backup attachment blob is invalid');
|
||||||
}
|
}
|
||||||
return parts.join('/');
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDispositionBackup(fileName: string | null | undefined): string {
|
||||||
|
const fallback = 'nodewarden_backup.zip';
|
||||||
|
const value = String(fileName || fallback)
|
||||||
|
.replace(/[\\/\r\n"]/g, '_')
|
||||||
|
.trim() || fallback;
|
||||||
|
return `attachment; filename="${value}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||||
@@ -224,6 +280,30 @@ async function uploadRemoteAttachmentChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyUploadedBackupArchive(
|
||||||
|
session: RemoteBackupTransferSession,
|
||||||
|
archive: BackupArchiveBundle
|
||||||
|
): Promise<'metadata' | 'download'> {
|
||||||
|
try {
|
||||||
|
const stat = await session.stat(archive.fileName);
|
||||||
|
if (stat?.size === archive.bytes.byteLength) {
|
||||||
|
return 'metadata';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to a full read-back verification when lightweight metadata is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteFile = await session.download(archive.fileName);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||||
|
if (!checksumOk) {
|
||||||
|
throw new Error('Remote backup ZIP checksum verification failed');
|
||||||
|
}
|
||||||
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||||
|
throw new Error('Remote backup ZIP size verification failed');
|
||||||
|
}
|
||||||
|
return 'download';
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeConfiguredBackup(
|
export async function executeConfiguredBackup(
|
||||||
env: Env,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
@@ -251,12 +331,14 @@ export async function executeConfiguredBackup(
|
|||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
destination.runtime.lastAttemptAt = now.toISOString();
|
|
||||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
|
||||||
destination.runtime.lastErrorAt = null;
|
|
||||||
destination.runtime.lastErrorMessage = null;
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastAttemptAt: now.toISOString(),
|
||||||
|
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
@@ -318,6 +400,7 @@ export async function executeConfiguredBackup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
|
||||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -337,14 +420,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
});
|
});
|
||||||
const remoteFile = await remoteSession.download(archive.fileName);
|
uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
|
||||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
|
||||||
if (!checksumOk) {
|
|
||||||
throw new Error('Remote backup ZIP checksum verification failed');
|
|
||||||
}
|
|
||||||
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
|
||||||
throw new Error('Remote backup ZIP size verification failed');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
@@ -373,14 +449,16 @@ export async function executeConfiguredBackup(
|
|||||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
destination.runtime.lastSuccessAt = new Date().toISOString();
|
|
||||||
destination.runtime.lastErrorAt = null;
|
|
||||||
destination.runtime.lastErrorMessage = null;
|
|
||||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
|
||||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
|
||||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastSuccessAt: new Date().toISOString(),
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
lastUploadedFileName: archive.fileName,
|
||||||
|
lastUploadedSizeBytes: archive.bytes.byteLength,
|
||||||
|
lastUploadedDestination: upload.remotePath,
|
||||||
|
}));
|
||||||
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
@@ -390,6 +468,7 @@ export async function executeConfiguredBackup(
|
|||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileBytes: archive.bytes.byteLength,
|
fileBytes: archive.bytes.byteLength,
|
||||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
|
uploadVerificationMethod,
|
||||||
prunedFileCount,
|
prunedFileCount,
|
||||||
pruneError: pruneErrorMessage,
|
pruneError: pruneErrorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
@@ -412,15 +491,18 @@ export async function executeConfiguredBackup(
|
|||||||
provider: upload.provider,
|
provider: upload.provider,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastErrorAt: new Date().toISOString(),
|
||||||
|
lastErrorMessage: errorMessage,
|
||||||
|
}));
|
||||||
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
});
|
});
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -431,7 +513,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
done: true,
|
done: true,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -591,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
|
|||||||
if (parsed.files[inlinePath]) continue;
|
if (parsed.files[inlinePath]) continue;
|
||||||
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
||||||
const blobName = String(ref?.blobName || '').trim();
|
const blobName = String(ref?.blobName || '').trim();
|
||||||
|
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
|
||||||
if (blobName && !seen.has(blobName)) {
|
if (blobName && !seen.has(blobName)) {
|
||||||
seen.add(blobName);
|
seen.add(blobName);
|
||||||
names.push(blobName);
|
names.push(blobName);
|
||||||
@@ -603,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
|
|||||||
function toImportStatusCode(message: string): number {
|
function toImportStatusCode(message: string): number {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (lower.includes('checksum')) return 400;
|
if (lower.includes('checksum')) return 400;
|
||||||
|
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
|
||||||
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||||
if (lower.includes('fresh instance')) return 409;
|
if (lower.includes('fresh instance')) return 409;
|
||||||
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||||
@@ -619,12 +703,18 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
checksumMismatchAccepted: boolean,
|
checksumMismatchAccepted: boolean,
|
||||||
auditMetadata: Record<string, unknown> | null = null,
|
auditMetadata: Record<string, unknown> | null = null,
|
||||||
targetDeviceIdentifier: string | null = null
|
targetDeviceIdentifier: string | null = null,
|
||||||
|
keepAlive?: (() => Promise<void>) | null
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const touchLease = async () => {
|
||||||
|
await keepAlive?.();
|
||||||
|
};
|
||||||
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
||||||
|
await touchLease();
|
||||||
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
||||||
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
||||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await touchLease();
|
||||||
await notifyUserBackupRestoreProgress(
|
await notifyUserBackupRestoreProgress(
|
||||||
env,
|
env,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
@@ -642,6 +732,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
{
|
{
|
||||||
loadAttachment: async (blobName) => {
|
loadAttachment: async (blobName) => {
|
||||||
|
await touchLease();
|
||||||
const normalized = String(blobName || '').trim();
|
const normalized = String(blobName || '').trim();
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
if (externalAttachmentCache.has(normalized)) {
|
if (externalAttachmentCache.has(normalized)) {
|
||||||
@@ -664,6 +755,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
} catch {
|
} catch {
|
||||||
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
||||||
}
|
}
|
||||||
|
await touchLease();
|
||||||
return externalAttachmentCache.get(normalized) || null;
|
return externalAttachmentCache.get(normalized) || null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -778,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
return jsonResponse(settings);
|
return jsonResponse(redactBackupSettingsSecrets(settings));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
||||||
}
|
}
|
||||||
@@ -787,13 +879,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
|||||||
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: BackupSettingsInput;
|
let body: BackupSettingsInput & { masterPasswordHash?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json<BackupSettingsInput>();
|
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup settings payload is invalid', 400);
|
return errorResponse('Backup settings payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let previous;
|
let previous;
|
||||||
try {
|
try {
|
||||||
@@ -814,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
|
|||||||
destinationCount: next.destinations.length,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
}, request);
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
@@ -837,13 +932,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
|
|||||||
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: BackupSettingsInput;
|
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
|
||||||
try {
|
try {
|
||||||
body = await request.json<BackupSettingsInput>();
|
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup settings repair payload is invalid', 400);
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let previous;
|
let previous;
|
||||||
try {
|
try {
|
||||||
@@ -864,22 +962,25 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
|||||||
destinationCount: next.destinations.length,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
}, request);
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let body: { destinationId?: string } | null = null;
|
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
body = await request.json<{ destinationId?: string }>();
|
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup run payload is invalid', 400);
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const outcome = await runConfiguredBackupInDurableObject(env, {
|
const outcome = await runConfiguredBackupInDurableObject(env, {
|
||||||
actorUserId: actorUser.id,
|
actorUserId: actorUser.id,
|
||||||
auditMetadata: auditRequestMetadata(request),
|
auditMetadata: auditRequestMetadata(request),
|
||||||
@@ -898,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
|||||||
provider: outcome.result.provider,
|
provider: outcome.result.provider,
|
||||||
remotePath: outcome.result.remotePath,
|
remotePath: outcome.result.remotePath,
|
||||||
},
|
},
|
||||||
settings: outcome.settings,
|
settings: redactBackupSettingsSecrets(outcome.settings),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
@@ -928,19 +1029,29 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
|
|||||||
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote backup download payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
return new Response(remoteFile.bytes, {
|
return new Response(remoteFile.bytes, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': remoteFile.contentType || 'application/zip',
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -951,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
|||||||
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote backup integrity payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -974,12 +1094,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
|
|||||||
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote backup delete payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
await deleteRemoteBackupFile(destination, path);
|
await deleteRemoteBackupFile(destination, path);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
@@ -994,13 +1123,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
|||||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
let body: {
|
||||||
|
destinationId?: string;
|
||||||
|
path?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
allowChecksumMismatch?: boolean;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Remote restore payload is invalid', 400);
|
return errorResponse('Remote restore payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
@@ -1028,14 +1166,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
let body: { includeAttachments?: boolean } | null = null;
|
let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
body = await request.json<{ includeAttachments?: boolean }>();
|
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Backup export payload is invalid', 400);
|
return errorResponse('Backup export payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
let archive: BackupArchiveBundle;
|
let archive: BackupArchiveBundle;
|
||||||
try {
|
try {
|
||||||
const progress = async (event: {
|
const progress = async (event: {
|
||||||
@@ -1096,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
'Content-Disposition': contentDispositionBackup(archive.fileName),
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
|
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input = {
|
||||||
|
blobName: url.searchParams.get('blobName') || '',
|
||||||
|
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(
|
||||||
|
actorUser,
|
||||||
|
String(input.masterPasswordHash || ''),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
|
const blobName = ensureBackupBlobName(String(input.blobName || ''));
|
||||||
const object = await getBlobObject(env, blobName);
|
const object = await getBlobObject(env, blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Backup attachment blob not found', 404);
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
@@ -1128,6 +1290,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const contentType = request.headers.get('Content-Type') || '';
|
||||||
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||||
|
}
|
||||||
|
const declaredSize = parseRequestContentLength(request);
|
||||||
|
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
|
||||||
|
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
|
||||||
|
}
|
||||||
|
|
||||||
let formData: FormData;
|
let formData: FormData;
|
||||||
try {
|
try {
|
||||||
formData = await request.formData();
|
formData = await request.formData();
|
||||||
@@ -1139,6 +1310,12 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||||
return errorResponse('Backup file is required', 400);
|
return errorResponse('Backup file is required', 400);
|
||||||
}
|
}
|
||||||
|
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||||
|
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
|
|||||||
+139
-15
@@ -7,6 +7,9 @@ import {
|
|||||||
CipherResponse,
|
CipherResponse,
|
||||||
CipherSecureNote,
|
CipherSecureNote,
|
||||||
CipherSshKey,
|
CipherSshKey,
|
||||||
|
CipherBankAccount,
|
||||||
|
CipherDriversLicense,
|
||||||
|
CipherPassport,
|
||||||
Attachment,
|
Attachment,
|
||||||
PasswordHistory,
|
PasswordHistory,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -32,6 +35,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
|||||||
// attachments, import/export, and current official clients.
|
// attachments, import/export, and current official clients.
|
||||||
export interface CipherResponseOptions {
|
export interface CipherResponseOptions {
|
||||||
preserveRepairableUris?: boolean;
|
preserveRepairableUris?: boolean;
|
||||||
|
validFolderIds?: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
||||||
@@ -48,6 +52,12 @@ function normalizeOptionalId(value: unknown): string | null {
|
|||||||
return normalized ? normalized : 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 {
|
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
|
||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
@@ -247,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
|
|||||||
return next as T;
|
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 {
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
@@ -347,6 +400,48 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
|
|||||||
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
|
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;
|
return null;
|
||||||
@@ -717,7 +812,20 @@ export function cipherToResponse(
|
|||||||
'licenseNumber',
|
'licenseNumber',
|
||||||
]);
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
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 }
|
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||||
: null;
|
: null;
|
||||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
@@ -727,8 +835,8 @@ export function cipherToResponse(
|
|||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
folderId: normalizeOptionalId(cipher.folderId),
|
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
||||||
type: Number(cipher.type) || 1,
|
type: responseType,
|
||||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||||
creationDate: createdAt,
|
creationDate: createdAt,
|
||||||
@@ -750,6 +858,9 @@ export function cipherToResponse(
|
|||||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||||
sshKey: normalizedSshKey,
|
sshKey: normalizedSshKey,
|
||||||
|
bankAccount: responseType === 6 ? normalizedBankAccount : null,
|
||||||
|
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
|
||||||
|
passport: responseType === 8 ? normalizedPassport : null,
|
||||||
key: responseCipherKey,
|
key: responseCipherKey,
|
||||||
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
||||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||||
@@ -785,9 +896,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||||
filteredCiphers.map((cipher) => cipher.id)
|
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.
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
@@ -804,7 +916,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
// GET /api/ciphers/:id
|
// GET /api/ciphers/:id
|
||||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -819,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> {
|
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||||
if (!folderId) return true;
|
if (!folderId) return true;
|
||||||
const folder = await storage.getFolder(folderId);
|
const folder = await storage.getFolderForUser(folderId, userId);
|
||||||
return !!(folder && folder.userId === userId);
|
return !!folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ciphers
|
// POST /api/ciphers
|
||||||
@@ -844,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
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']);
|
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
|
||||||
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
||||||
@@ -873,6 +988,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? 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);
|
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
@@ -901,7 +1019,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const existingCipher = await storage.getCipher(id);
|
const existingCipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!existingCipher || existingCipher.userId !== userId) {
|
if (!existingCipher || existingCipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -924,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
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 incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||||
@@ -972,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.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.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.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) {
|
if (incomingPasswordHistory.present) {
|
||||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1136,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1044,7 +1168,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
// - If item is already soft-deleted -> hard delete.
|
// - If item is already soft-deleted -> hard delete.
|
||||||
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1071,7 +1195,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
|||||||
// DELETE /api/ciphers/:id (permanent)
|
// DELETE /api/ciphers/:id (permanent)
|
||||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1096,7 +1220,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
// PUT /api/ciphers/:id/restore
|
// PUT /api/ciphers/:id/restore
|
||||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1118,7 +1242,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1210,7 +1334,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
|||||||
// PUT/POST /api/ciphers/:id/archive
|
// PUT/POST /api/ciphers/:id/archive
|
||||||
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
@@ -1236,7 +1360,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
// PUT/POST /api/ciphers/:id/unarchive
|
// PUT/POST /api/ciphers/:id/unarchive
|
||||||
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipherForUser(id, userId);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
return errorResponse('Cipher not found', 404);
|
return errorResponse('Cipher not found', 404);
|
||||||
|
|||||||
+98
-2
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
|||||||
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
||||||
@@ -48,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
|||||||
creationDate: device.createdAt,
|
creationDate: device.createdAt,
|
||||||
RevisionDate: device.updatedAt,
|
RevisionDate: device.updatedAt,
|
||||||
revisionDate: device.updatedAt,
|
revisionDate: device.updatedAt,
|
||||||
|
LastActivityDate: device.lastSeenAt,
|
||||||
|
lastActivityDate: device.lastSeenAt,
|
||||||
LastSeenAt: device.lastSeenAt,
|
LastSeenAt: device.lastSeenAt,
|
||||||
lastSeenAt: device.lastSeenAt,
|
lastSeenAt: device.lastSeenAt,
|
||||||
HasStoredDevice: true,
|
HasStoredDevice: true,
|
||||||
@@ -123,6 +125,85 @@ function parseDeviceName(value: unknown): string {
|
|||||||
return String(value || '').trim().slice(0, 128);
|
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
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
// - X-Request-Email: base64url(email) without padding
|
// - X-Request-Email: base64url(email) without padding
|
||||||
@@ -383,11 +464,26 @@ export async function handleUpdateDeviceName(
|
|||||||
|
|
||||||
// DELETE /api/devices
|
// DELETE /api/devices
|
||||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let masterPasswordHash = '';
|
||||||
|
try {
|
||||||
|
const body = await request.json() as { masterPasswordHash?: string };
|
||||||
|
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
|
||||||
|
} catch {
|
||||||
|
masterPasswordHash = '';
|
||||||
|
}
|
||||||
|
if (!masterPasswordHash) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!passwordValid) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
storage.deleteRefreshTokensByUserId(userId),
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
|
||||||
|
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
|
||||||
|
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
|
||||||
|
|
||||||
|
const EMPTY_FORMS_BODY = JSON.stringify({
|
||||||
|
schemaVersion: '1.0.0',
|
||||||
|
hosts: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Bitwarden Fill Assist Forms v1',
|
||||||
|
type: 'object',
|
||||||
|
required: ['schemaVersion', 'hosts'],
|
||||||
|
properties: {
|
||||||
|
schemaVersion: { type: 'string' },
|
||||||
|
hosts: { type: 'object' },
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_MANIFEST_BODY = JSON.stringify({
|
||||||
|
buildId: 'nodewarden-empty-fill-assist-v1',
|
||||||
|
timestamp: '2026-07-06T00:00:00.000Z',
|
||||||
|
gitSha: 'nodewarden',
|
||||||
|
maps: {
|
||||||
|
forms: {
|
||||||
|
v1: {
|
||||||
|
filename: EMPTY_FORMS_FILENAME,
|
||||||
|
cid: EMPTY_FORMS_CID,
|
||||||
|
schema: EMPTY_FORMS_SCHEMA_FILENAME,
|
||||||
|
deprecated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
|
||||||
|
linked: false,
|
||||||
|
maxAge: '86400s',
|
||||||
|
debugString: 'No matching digital asset link policy is configured for this server.',
|
||||||
|
});
|
||||||
|
|
||||||
|
function fillAssistJsonResponse(body: string): Response {
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilename(filename: string): string {
|
||||||
|
const raw = String(filename || '').trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFillAssistManifest(): Response {
|
||||||
|
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFillAssistForms(filename: string): Response {
|
||||||
|
const normalized = normalizeFilename(filename);
|
||||||
|
if (normalized === EMPTY_FORMS_FILENAME) {
|
||||||
|
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
|
||||||
|
}
|
||||||
|
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
|
||||||
|
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
|
||||||
|
}
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDigitalAssetLinkCheck(): Response {
|
||||||
|
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
|
|||||||
// GET /api/folders/:id
|
// GET /api/folders/:id
|
||||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolderForUser(id, userId);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
// PUT /api/folders/:id
|
// PUT /api/folders/:id
|
||||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolderForUser(id, userId);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
// DELETE /api/folders/:id
|
// DELETE /api/folders/:id
|
||||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolderForUser(id, userId);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const folders = (
|
const folders = (
|
||||||
await Promise.all(ids.map(async (id) => {
|
await Promise.all(ids.map(async (id) => {
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolderForUser(id, userId);
|
||||||
return folder && folder.userId === userId ? folder : null;
|
return folder;
|
||||||
}))
|
}))
|
||||||
).filter((folder): folder is Folder => !!folder);
|
).filter((folder): folder is Folder => !!folder);
|
||||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
|
|||||||
+252
-99
@@ -1,10 +1,10 @@
|
|||||||
import { Env, TokenResponse } from '../types';
|
import { Env, TokenResponse, User } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
import { LIMITS } from '../config/limits';
|
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||||
import { createRefreshToken } from '../utils/jwt';
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
@@ -18,21 +18,34 @@ import {
|
|||||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
import {
|
import {
|
||||||
assertAccountPasskeyCredential,
|
assertAccountPasskeyCredential,
|
||||||
|
assertTwoFactorPasskeyCredential,
|
||||||
buildAccountPasskeyTokenUserDecryptionOption,
|
buildAccountPasskeyTokenUserDecryptionOption,
|
||||||
|
buildTwoFactorPasskeyAssertionOptions,
|
||||||
} from './account-passkeys';
|
} from './account-passkeys';
|
||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
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_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
|
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||||
|
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||||
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
|
// 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
|
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||||
// compatible with older/local provider values.
|
// compatible with older/local provider values.
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
||||||
|
|
||||||
|
function identityJsonResponse(data: unknown, status: number = 200): Response {
|
||||||
|
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
|
||||||
|
}
|
||||||
|
|
||||||
function resolveTotpSecret(userSecret: string | null): string | null {
|
function resolveTotpSecret(userSecret: string | null): string | null {
|
||||||
if (userSecret && isTotpEnabled(userSecret)) {
|
if (userSecret && isTotpEnabled(userSecret)) {
|
||||||
return userSecret;
|
return userSecret;
|
||||||
@@ -51,6 +64,33 @@ async function resolveDeviceSession(
|
|||||||
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
|
||||||
|
if (shouldUseWebSession(request)) return 'web';
|
||||||
|
const clientId = String(body.client_id || '').trim().toLowerCase();
|
||||||
|
if (clientId === 'mobile') return 'mobile';
|
||||||
|
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
|
||||||
|
return clientId || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistAndResolveDeviceSession(
|
||||||
|
storage: StorageService,
|
||||||
|
userId: string,
|
||||||
|
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
|
||||||
|
): Promise<{ identifier: string; sessionStamp: string } | null> {
|
||||||
|
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
|
||||||
|
if (!candidate) return null;
|
||||||
|
await storage.upsertDevice(
|
||||||
|
userId,
|
||||||
|
candidate.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
candidate.sessionStamp
|
||||||
|
);
|
||||||
|
const persisted = await storage.getDevice(userId, candidate.identifier);
|
||||||
|
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
|
||||||
|
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
function readDevicePushToken(body: Record<string, string>): string {
|
function readDevicePushToken(body: Record<string, string>): string {
|
||||||
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
|
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
|
||||||
}
|
}
|
||||||
@@ -105,18 +145,6 @@ function parseCookieValue(request: Request, name: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function constantTimeEquals(a: string, b: string): boolean {
|
|
||||||
const encA = new TextEncoder().encode(a);
|
|
||||||
const encB = new TextEncoder().encode(b);
|
|
||||||
if (encA.length !== encB.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < encA.length; i++) {
|
|
||||||
diff |= encA[i] ^ encB[i];
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const value = body[name];
|
const value = body[name];
|
||||||
@@ -125,6 +153,25 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
|
|||||||
return undefined;
|
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 {
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
const isHttps = new URL(request.url).protocol === 'https:';
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -147,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
|
|||||||
headers.append(
|
headers.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
refreshToken
|
refreshToken
|
||||||
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
|
||||||
: buildClearedRefreshCookie(request)
|
: buildClearedRefreshCookie(request)
|
||||||
);
|
);
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
@@ -193,13 +240,32 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
async function twoFactorRequiredResponse(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
user?: User,
|
||||||
|
message: string = 'Two factor required.'
|
||||||
|
): Promise<Response> {
|
||||||
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
// 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.
|
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||||
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
const providers: string[] = [];
|
||||||
const providers2: Record<string, { Email: null }> = {};
|
let webAuthnOptions: Record<string, unknown> | null = null;
|
||||||
for (const provider of providers) providers2[provider] = { Email: 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 = {
|
const customResponse = {
|
||||||
TwoFactorProviders: providers,
|
TwoFactorProviders: providers,
|
||||||
TwoFactorProviders2: providers2,
|
TwoFactorProviders2: providers2,
|
||||||
@@ -208,7 +274,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
return jsonResponse(
|
return identityJsonResponse(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
error_description: message,
|
error_description: message,
|
||||||
@@ -282,8 +348,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
const clientIdentifier = getClientIdentifier(request);
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
if (!clientIdentifier) {
|
if (!clientIdentifier && grantType !== 'refresh_token') {
|
||||||
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
|
await safeWriteAuditEvent(env, {
|
||||||
|
action: 'auth.client_ip.missing',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'error',
|
||||||
|
targetType: 'tokenEndpoint',
|
||||||
|
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
return identityErrorResponse(
|
||||||
|
'Authentication is temporarily unavailable',
|
||||||
|
'temporarily_unavailable',
|
||||||
|
503,
|
||||||
|
{ 'Retry-After': '5' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (grantType === 'password') {
|
if (grantType === 'password') {
|
||||||
@@ -294,13 +372,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
||||||
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
||||||
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
||||||
const loginIdentifier = clientIdentifier;
|
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
// Bitwarden clients expect OAuth-style error fields.
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, email);
|
||||||
|
|
||||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
@@ -336,10 +414,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let validatedAuthRequestId: string | null = null;
|
let validatedAuthRequestId: string | null = null;
|
||||||
|
let authRequestLoginKey: string | null = null;
|
||||||
let valid = false;
|
let valid = false;
|
||||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||||
if (normalizedAuthRequestId) {
|
if (normalizedAuthRequestId) {
|
||||||
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
|
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||||
valid = !!(
|
valid = !!(
|
||||||
authRequest &&
|
authRequest &&
|
||||||
authRequest.userId === user.id &&
|
authRequest.userId === user.id &&
|
||||||
@@ -348,10 +427,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
authRequest.responseDate &&
|
authRequest.responseDate &&
|
||||||
!authRequest.authenticationDate &&
|
!authRequest.authenticationDate &&
|
||||||
!isAuthRequestExpired(authRequest) &&
|
!isAuthRequestExpired(authRequest) &&
|
||||||
|
!!authRequest.key &&
|
||||||
constantTimeEquals(authRequest.accessCode, passwordHash)
|
constantTimeEquals(authRequest.accessCode, passwordHash)
|
||||||
);
|
);
|
||||||
if (valid) {
|
if (valid) {
|
||||||
validatedAuthRequestId = authRequest!.id;
|
validatedAuthRequestId = authRequest!.id;
|
||||||
|
authRequestLoginKey = authRequest!.key;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||||
@@ -377,10 +458,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;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||||
if (effectiveTotpSecret) {
|
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
||||||
|
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
|
||||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||||
@@ -390,7 +473,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,
|
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||||
// respond with a 2FA challenge payload.
|
// respond with a 2FA challenge payload.
|
||||||
if (!hasProvider || !hasToken) {
|
if (!hasProvider || !hasToken) {
|
||||||
return twoFactorRequiredResponse('Two factor required.');
|
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let passedByRememberToken = false;
|
let passedByRememberToken = false;
|
||||||
@@ -405,11 +488,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
if (!passedByRememberToken) {
|
if (!passedByRememberToken) {
|
||||||
return twoFactorRequiredResponse('Two factor required.');
|
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||||
}
|
}
|
||||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
if (!effectiveTotpSecret) {
|
||||||
if (!totpOk) {
|
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);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
@@ -421,10 +535,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
user.totpSecret = null;
|
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.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await storage.deleteRefreshTokensByUserId(user.id);
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
rememberRequested = false;
|
rememberRequested = false;
|
||||||
} else {
|
} else {
|
||||||
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||||
@@ -444,15 +569,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist device only after successful password + (optional) 2FA verification.
|
// Persist device only after successful password + (optional) 2FA verification.
|
||||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
|
||||||
if (deviceSession) {
|
if (deviceSession) {
|
||||||
await storage.upsertDevice(
|
|
||||||
user.id,
|
|
||||||
deviceSession.identifier,
|
|
||||||
deviceInfo.deviceName,
|
|
||||||
deviceInfo.deviceType,
|
|
||||||
deviceSession.sessionStamp
|
|
||||||
);
|
|
||||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +581,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
await safeWriteAuditEvent(env, {
|
await safeWriteAuditEvent(env, {
|
||||||
@@ -488,7 +606,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: authRequestLoginKey || user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: accountKeys,
|
AccountKeys: accountKeys,
|
||||||
accountKeys: accountKeys,
|
accountKeys: accountKeys,
|
||||||
@@ -506,13 +624,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
userDecryptionOptions: userDecryptionOptions,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = identityJsonResponse(response);
|
||||||
return shouldUseWebSession(request)
|
return shouldUseWebSession(request)
|
||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'webauthn') {
|
} else if (grantType === 'webauthn') {
|
||||||
const loginIdentifier = clientIdentifier;
|
const token = String(body.token || '').trim();
|
||||||
|
const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
if (!loginCheck.allowed) {
|
if (!loginCheck.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
@@ -522,7 +641,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(body.token || '').trim();
|
|
||||||
let deviceResponse: unknown = body.deviceResponse;
|
let deviceResponse: unknown = body.deviceResponse;
|
||||||
if (typeof deviceResponse === 'string') {
|
if (typeof deviceResponse === 'string') {
|
||||||
try {
|
try {
|
||||||
@@ -567,22 +685,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
|
||||||
if (deviceSession) {
|
if (deviceSession) {
|
||||||
await storage.upsertDevice(
|
|
||||||
user.id,
|
|
||||||
deviceSession.identifier,
|
|
||||||
deviceInfo.deviceName,
|
|
||||||
deviceInfo.deviceType,
|
|
||||||
deviceSession.sessionStamp
|
|
||||||
);
|
|
||||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
|
||||||
|
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
||||||
@@ -621,11 +733,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
|
UserVerificationToken: userVerificationToken,
|
||||||
|
userVerificationToken,
|
||||||
UserDecryptionOptions: userDecryptionOptions,
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: userDecryptionOptions,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = identityJsonResponse(response);
|
||||||
return shouldUseWebSession(request)
|
return shouldUseWebSession(request)
|
||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
@@ -637,11 +751,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const scope = body.scope;
|
const scope = body.scope;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
const loginIdentifier = clientIdentifier;
|
|
||||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||||
if (!parmValid) {
|
if (!parmValid) {
|
||||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
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
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
@@ -653,7 +768,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uid = clientId.slice(5);
|
|
||||||
const user = await storage.getUserById(uid);
|
const user = await storage.getUserById(uid);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
@@ -677,7 +791,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
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 rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
await safeWriteAuditEvent(env, {
|
await safeWriteAuditEvent(env, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
@@ -696,15 +810,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist device only after successful client credential verification.
|
// Persist device only after successful client credential verification.
|
||||||
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
|
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
|
||||||
if (deviceSession) {
|
if (deviceSession) {
|
||||||
await storage.upsertDevice(
|
|
||||||
user.id,
|
|
||||||
deviceSession.identifier,
|
|
||||||
deviceInfo.deviceName,
|
|
||||||
deviceInfo.deviceType,
|
|
||||||
deviceSession.sessionStamp
|
|
||||||
);
|
|
||||||
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +819,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
await safeWriteAuditEvent(env, {
|
await safeWriteAuditEvent(env, {
|
||||||
@@ -754,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
userDecryptionOptions: userDecryptionOptions,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = identityJsonResponse(response);
|
||||||
return shouldUseWebSession(request)
|
return shouldUseWebSession(request)
|
||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
@@ -771,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const sendId = String(body.send_id || body.sendId || '').trim();
|
const sendId = String(body.send_id || body.sendId || '').trim();
|
||||||
if (!sendId) {
|
if (!sendId) {
|
||||||
return jsonResponse(
|
return identityJsonResponse(
|
||||||
{
|
{
|
||||||
error: 'invalid_request',
|
error: 'invalid_request',
|
||||||
error_description: 'send_id is required',
|
error_description: 'send_id is required',
|
||||||
@@ -796,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
passwordHashB64,
|
passwordHashB64,
|
||||||
password,
|
password,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
`${clientIdentifier}:send-password`
|
clientIdentifier || undefined
|
||||||
);
|
);
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
return result.error;
|
return result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return identityJsonResponse({
|
||||||
access_token: result.token,
|
access_token: result.token,
|
||||||
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
|
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
@@ -810,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
});
|
});
|
||||||
} else if (grantType === 'refresh_token') {
|
} else if (grantType === 'refresh_token') {
|
||||||
const refreshLimit = await rateLimit.consumeBudget(
|
|
||||||
`${clientIdentifier}:identity-refresh`,
|
|
||||||
LIMITS.rateLimit.refreshTokenRequestsPerMinute
|
|
||||||
);
|
|
||||||
if (!refreshLimit.allowed) {
|
|
||||||
return identityErrorResponse(
|
|
||||||
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
|
|
||||||
'TooManyRequests',
|
|
||||||
429
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh token
|
|
||||||
const refreshToken = String(body.refresh_token || '').trim() || (
|
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||||
shouldUseWebSession(request)
|
shouldUseWebSession(request)
|
||||||
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||||
@@ -832,7 +926,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessTokenDetailed(refreshToken);
|
const refreshTokenHash = await sha256Hex(refreshToken);
|
||||||
|
try {
|
||||||
|
const sessionLimit = await rateLimit.consumeBudget(
|
||||||
|
`refresh-session:${refreshTokenHash}`,
|
||||||
|
LIMITS.rateLimit.refreshTokenRequestsPerMinute
|
||||||
|
);
|
||||||
|
const ipLimit = clientIdentifier
|
||||||
|
? await rateLimit.consumeBudget(
|
||||||
|
`refresh-ip:${clientIdentifier}`,
|
||||||
|
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
|
||||||
|
if (rejected) {
|
||||||
|
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
||||||
|
'temporarily_unavailable',
|
||||||
|
429,
|
||||||
|
{ 'Retry-After': String(retryAfter) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
action: 'auth.refresh.failed.rate_limit_unavailable',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'error',
|
||||||
|
targetType: 'refreshToken',
|
||||||
|
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
return identityErrorResponse(
|
||||||
|
'Session refresh is temporarily unavailable',
|
||||||
|
'temporarily_unavailable',
|
||||||
|
503,
|
||||||
|
{ 'Retry-After': '5' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
action: 'auth.client_ip.missing',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'refreshToken',
|
||||||
|
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
|
||||||
|
try {
|
||||||
|
result = await auth.refreshAccessTokenDetailed(refreshToken);
|
||||||
|
} catch (error) {
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
action: 'auth.refresh.failed.temporarily_unavailable',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'error',
|
||||||
|
targetType: 'refreshToken',
|
||||||
|
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
return identityErrorResponse(
|
||||||
|
'Session refresh is temporarily unavailable',
|
||||||
|
'temporarily_unavailable',
|
||||||
|
503,
|
||||||
|
{ 'Retry-After': '5' }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
await safeWriteAuditEvent(env, {
|
await safeWriteAuditEvent(env, {
|
||||||
actorUserId: result.userId ?? null,
|
actorUserId: result.userId ?? null,
|
||||||
@@ -854,18 +1013,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
: invalidResponse;
|
: invalidResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for old refresh token to absorb
|
|
||||||
// concurrent refresh requests from multiple client contexts.
|
|
||||||
await storage.constrainRefreshTokenExpiry(
|
|
||||||
refreshToken,
|
|
||||||
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
|
||||||
);
|
|
||||||
|
|
||||||
const { accessToken, user, device } = result;
|
const { accessToken, user, device } = result;
|
||||||
if (device?.identifier) {
|
if (device?.identifier) {
|
||||||
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||||
}
|
}
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
@@ -873,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: accountKeys,
|
AccountKeys: accountKeys,
|
||||||
@@ -892,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
userDecryptionOptions: userDecryptionOptions,
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = identityJsonResponse(response);
|
||||||
return shouldUseWebSession(request)
|
return shouldUseWebSession(request)
|
||||||
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
const kdfMemory = user?.kdfMemory ?? null;
|
const kdfMemory = user?.kdfMemory ?? null;
|
||||||
const kdfParallelism = user?.kdfParallelism ?? null;
|
const kdfParallelism = user?.kdfParallelism ?? null;
|
||||||
|
|
||||||
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/connect/revocation
|
// POST /identity/connect/revocation
|
||||||
@@ -935,7 +1086,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
// RFC 7009 allows returning 200 even if token is unknown.
|
// RFC 7009 allows returning 200 even if token is unknown.
|
||||||
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: Record<string, string>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
try {
|
try {
|
||||||
@@ -946,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
body = await request.json();
|
body = await request.json();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(body.token || '').trim() || (
|
const token = String(body.token || '').trim() || (
|
||||||
@@ -958,7 +1108,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
await storage.deleteRefreshToken(token);
|
await storage.deleteRefreshToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseResponse = new Response(null, { status: 200 });
|
const baseResponse = new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
|
||||||
|
});
|
||||||
return shouldUseWebSession(request)
|
return shouldUseWebSession(request)
|
||||||
? withWebRefreshCookie(request, baseResponse, null)
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
: baseResponse;
|
: baseResponse;
|
||||||
|
|||||||
+27
-7
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
|
|||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
reprompt?: number;
|
reprompt?: number;
|
||||||
sshKey?: any | null;
|
sshKey?: any | null;
|
||||||
|
bankAccount?: any | null;
|
||||||
|
driversLicense?: any | null;
|
||||||
|
passport?: any | null;
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
login?: {
|
login?: {
|
||||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
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;
|
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> {
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
const chunk = statements.slice(i, 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);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = importData.folders || [];
|
const folders = Array.isArray(importData.folders) ? importData.folders : [];
|
||||||
const ciphers = importData.ciphers || [];
|
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
|
||||||
const folderRelationships = importData.folderRelationships || [];
|
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
|
||||||
|
|
||||||
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
||||||
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
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[] = [];
|
const folderRows: Folder[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
|
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
|
||||||
const folderId = generateUUID();
|
const folderId = generateUUID();
|
||||||
folderIdMap.set(i, folderId);
|
folderIdMap.set(i, folderId);
|
||||||
|
|
||||||
const folder: Folder = {
|
const folder: Folder = {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
name: folders[i].name,
|
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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
|
// Build cipher index -> folder id mapping from relationships
|
||||||
const cipherFolderMap = new Map<number, string>();
|
const cipherFolderMap = new Map<number, string>();
|
||||||
for (const rel of folderRelationships) {
|
for (const rel of folderRelationships) {
|
||||||
|
if (!rel || typeof rel !== 'object') continue;
|
||||||
const folderId = folderIdMap.get(rel.value);
|
const folderId = folderIdMap.get(rel.value);
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
cipherFolderMap.set(rel.key, folderId);
|
cipherFolderMap.set(rel.key, folderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
const cipherRows: Cipher[] = [];
|
const cipherRows: Cipher[] = [];
|
||||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
|
||||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
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 sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
const sourceId = sourceIdRaw || null;
|
const sourceId = sourceIdRaw || null;
|
||||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
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 fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||||
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: passwordHistory ?? null,
|
passwordHistory: passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
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,
|
key: key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { AuthService } from '../services/auth';
|
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 type { Env, JWTPayload } from '../types';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
|
|||||||
return errorResponse('Expected websocket', 426);
|
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 id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
const forwardedUrl = new URL(request.url);
|
const forwardedUrl = new URL(request.url);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
import {
|
import {
|
||||||
getBlobStorageMaxBytes,
|
getBlobStorageMaxBytes,
|
||||||
getSendFileObjectKey,
|
getSendFileObjectKey,
|
||||||
|
getBlobObject,
|
||||||
putBlobObject,
|
putBlobObject,
|
||||||
deleteBlobObject,
|
deleteBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
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(
|
async function writeSendAudit(
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -82,8 +85,13 @@ async function processSendFileUpload(
|
|||||||
return upload;
|
return upload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const path = getSendFileObjectKey(send.id, fileId);
|
||||||
|
if (await getBlobObject(env, path)) {
|
||||||
|
return errorResponse('Send file has already been uploaded', 409);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
|
await putBlobObject(env, path, upload.body, {
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
contentType: upload.contentType,
|
contentType: upload.contentType,
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
@@ -134,7 +142,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
|
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
@@ -210,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
if (authTypeRaw.present && requestedAuthType === null) {
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
|
if (requestedAuthType === SendAuthType.Email) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const send: Send = {
|
const send: Send = {
|
||||||
@@ -334,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
if (authTypeRaw.present && requestedAuthType === null) {
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
|
if (requestedAuthType === SendAuthType.Email) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const send: Send = {
|
const send: Send = {
|
||||||
@@ -401,7 +421,7 @@ export async function handleGetSendFileUpload(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
}
|
}
|
||||||
@@ -436,7 +456,7 @@ export async function handleUploadSendFile(
|
|||||||
fileId: string
|
fileId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
}
|
}
|
||||||
@@ -472,7 +492,7 @@ export async function handlePublicUploadSendFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const 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) {
|
if (!send || send.userId !== claims.userId) {
|
||||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
}
|
}
|
||||||
@@ -485,7 +505,7 @@ export async function handlePublicUploadSendFile(
|
|||||||
|
|
||||||
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
}
|
}
|
||||||
@@ -592,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
if (parsedAuthType === null) {
|
if (parsedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
send.authType = parsedAuthType;
|
if (parsedAuthType === SendAuthType.Email) {
|
||||||
if (parsedAuthType !== SendAuthType.Email) {
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
send.emails = null;
|
|
||||||
}
|
}
|
||||||
|
send.authType = parsedAuthType;
|
||||||
|
send.emails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
@@ -604,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
if (emailsRaw.value !== null && normalizedEmails === null) {
|
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
send.emails = normalizedEmails;
|
send.emails = normalizedEmails;
|
||||||
if (send.emails) {
|
if (send.emails) {
|
||||||
send.authType = SendAuthType.Email;
|
send.authType = SendAuthType.Email;
|
||||||
} else if (send.authType === SendAuthType.Email) {
|
} else if (Number(send.authType) === SendAuthType.Email) {
|
||||||
send.authType = SendAuthType.None;
|
send.authType = SendAuthType.None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,7 +656,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
}
|
}
|
||||||
@@ -698,7 +722,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
}
|
}
|
||||||
@@ -719,7 +743,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
|||||||
|
|
||||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSendForUser(sendId, userId);
|
||||||
if (!send || send.userId !== userId) {
|
if (!send || send.userId !== userId) {
|
||||||
return errorResponse('Send not found', 404);
|
return errorResponse('Send not found', 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { LIMITS } from '../config/limits';
|
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||||
import {
|
import {
|
||||||
createSendAccessToken,
|
createSendAccessToken,
|
||||||
createSendFileDownloadToken,
|
createSendFileDownloadToken,
|
||||||
@@ -68,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
|||||||
if (!clientIdentifier) {
|
if (!clientIdentifier) {
|
||||||
return errorResponse('Client IP is required', 403);
|
return errorResponse('Client IP is required', 403);
|
||||||
}
|
}
|
||||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
if (!sendPasswordCheck.allowed) {
|
if (!sendPasswordCheck.allowed) {
|
||||||
@@ -112,10 +112,9 @@ export async function handleAccessSendFile(
|
|||||||
idOrAccessId: string,
|
idOrAccessId: string,
|
||||||
fileId: string
|
fileId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const safeSecret = getSafeJwtSecret(env);
|
||||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
if (!safeSecret.ok) return safeSecret.response;
|
||||||
return errorResponse('Server configuration error', 500);
|
const { secret } = safeSecret;
|
||||||
}
|
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||||
@@ -143,7 +142,7 @@ export async function handleAccessSendFile(
|
|||||||
if (!clientIdentifier) {
|
if (!clientIdentifier) {
|
||||||
return errorResponse('Client IP is required', 403);
|
return errorResponse('Client IP is required', 403);
|
||||||
}
|
}
|
||||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
if (!sendPasswordCheck.allowed) {
|
if (!sendPasswordCheck.allowed) {
|
||||||
@@ -291,22 +290,30 @@ export async function handleDownloadSendFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
|
||||||
if (!object) {
|
|
||||||
return errorResponse('Send file not found', 404);
|
|
||||||
}
|
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
const data = send ? parseStoredSendData(send) : {};
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
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);
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(env, 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, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': object.contentType || 'application/octet-stream',
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Content-Disposition': contentDispositionAttachment(fileName),
|
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
@@ -321,7 +328,7 @@ export async function issueSendAccessToken(
|
|||||||
passwordHashB64?: string | null,
|
passwordHashB64?: string | null,
|
||||||
password?: string | null,
|
password?: string | null,
|
||||||
rateLimit?: RateLimitService,
|
rateLimit?: RateLimitService,
|
||||||
sendPasswordLimitIpKey?: string
|
clientIdentifier?: string
|
||||||
): Promise<{ token: string } | { error: Response }> {
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
const jwt = getSafeJwtSecret(env);
|
const jwt = getSafeJwtSecret(env);
|
||||||
if (!jwt.ok) {
|
if (!jwt.ok) {
|
||||||
@@ -361,11 +368,14 @@ export async function issueSendAccessToken(
|
|||||||
Object: 'error',
|
Object: 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400
|
501
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendPasswordLimitIpKey =
|
||||||
|
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
|
||||||
|
|
||||||
if (send.passwordHash) {
|
if (send.passwordHash) {
|
||||||
if (rateLimit && sendPasswordLimitIpKey) {
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||||
import {
|
import {
|
||||||
notifyUserSendCreate,
|
notifyUserSendCreate,
|
||||||
notifyUserSendDelete,
|
notifyUserSendDelete,
|
||||||
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
export function parseDate(raw: unknown): Date | null {
|
export function parseDate(raw: unknown): Date | null {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
const date = new Date(raw);
|
let value = raw.trim();
|
||||||
|
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
||||||
|
value += 'Z';
|
||||||
|
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
|
||||||
|
value = value.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
@@ -371,7 +379,7 @@ export function hasEmailAuth(send: Send): boolean {
|
|||||||
|
|
||||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
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: false, response: errorResponse('Server configuration error', 500) };
|
||||||
}
|
}
|
||||||
return { ok: true, secret };
|
return { ok: true, secret };
|
||||||
@@ -434,8 +442,8 @@ export type PublicSendAccessValidationResult =
|
|||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||||
|
|
||||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
|
||||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||||
@@ -464,7 +472,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
|
|||||||
|
|
||||||
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||||
if (hasEmailAuth(send)) {
|
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 };
|
if (!send.passwordHash) return { ok: true };
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
.map(buildWebAuthnPrfOption)
|
.map(buildWebAuthnPrfOption)
|
||||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||||
|
const validFolderIds = new Set(folders.map((folder) => folder.id));
|
||||||
|
|
||||||
const profile: ProfileResponse = buildProfileResponse(user, env);
|
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||||
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
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)) {
|
if (isCipherResponseSyncCompatible(response)) {
|
||||||
cipherResponses.push(response);
|
cipherResponses.push(response);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
|
|||||||
path.startsWith('/api/') ||
|
path.startsWith('/api/') ||
|
||||||
path.startsWith('/identity/') ||
|
path.startsWith('/identity/') ||
|
||||||
path.startsWith('/icons/') ||
|
path.startsWith('/icons/') ||
|
||||||
|
path.startsWith('/fill-assist/') ||
|
||||||
path.startsWith('/notifications/') ||
|
path.startsWith('/notifications/') ||
|
||||||
path.startsWith('/.well-known/') ||
|
path.startsWith('/.well-known/') ||
|
||||||
|
path === '/v1/assetlinks:check' ||
|
||||||
|
path === '/web-bootstrap' ||
|
||||||
path === '/config' ||
|
path === '/config' ||
|
||||||
path === '/api/config' ||
|
path === '/api/config' ||
|
||||||
path === '/api/version'
|
path === '/api/version'
|
||||||
@@ -89,7 +92,7 @@ export default {
|
|||||||
const normalizedRequest = normalizeRequestUrl(request);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(normalizedRequest, assetResponse);
|
return applyCors(normalizedRequest, assetResponse, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -107,11 +110,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(normalizedRequest, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/blob' && method === 'GET') {
|
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +50,11 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleListAdminRemoteBackups(request, env, actorUser);
|
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);
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
|
||||||
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -4,7 +4,7 @@ import {
|
|||||||
handleAdminCreateInvite,
|
handleAdminCreateInvite,
|
||||||
handleAdminListInvites,
|
handleAdminListInvites,
|
||||||
handleAdminDeleteAllInvites,
|
handleAdminDeleteAllInvites,
|
||||||
handleAdminRevokeInvite,
|
handleAdminDeleteInvite,
|
||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
handleAdminListAuditLogs,
|
handleAdminListAuditLogs,
|
||||||
@@ -13,6 +13,23 @@ import {
|
|||||||
handleAdminClearAuditLogs,
|
handleAdminClearAuditLogs,
|
||||||
} from './handlers/admin';
|
} from './handlers/admin';
|
||||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
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(
|
export async function handleAdminRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
|
|||||||
path: string,
|
path: string,
|
||||||
method: string
|
method: string
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
|
if (!isKnownAdminPath(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isActiveAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/users' && method === 'GET') {
|
if (path === '/api/admin/users' && method === 'GET') {
|
||||||
return handleAdminListUsers(request, env, actorUser);
|
return handleAdminListUsers(request, env, actorUser);
|
||||||
}
|
}
|
||||||
@@ -52,7 +76,7 @@ export async function handleAdminRoute(
|
|||||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||||
if (adminInviteMatch && method === 'DELETE') {
|
if (adminInviteMatch && method === 'DELETE') {
|
||||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
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);
|
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Env, User } from './types';
|
import type { Env, User } from './types';
|
||||||
import { errorResponse, jsonResponse } from './utils/response';
|
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
|
||||||
import {
|
import {
|
||||||
handleGetProfile,
|
handleGetProfile,
|
||||||
handleUpdateProfile,
|
handleUpdateProfile,
|
||||||
@@ -15,6 +15,12 @@ import {
|
|||||||
handleGetTwoFactorProviders,
|
handleGetTwoFactorProviders,
|
||||||
handleGetTwoFactorAuthenticator,
|
handleGetTwoFactorAuthenticator,
|
||||||
handlePutTwoFactorAuthenticator,
|
handlePutTwoFactorAuthenticator,
|
||||||
|
handleGetTwoFactorYubiKey,
|
||||||
|
handlePutTwoFactorYubiKey,
|
||||||
|
handlePutTwoFactorYubiKeyConfig,
|
||||||
|
handleBootstrapTwoFactorYubiKeyConfig,
|
||||||
|
handleGetDeviceVerificationSettings,
|
||||||
|
handlePutDeviceVerificationSettings,
|
||||||
handleDisableTwoFactorProvider,
|
handleDisableTwoFactorProvider,
|
||||||
handleGetApiKey,
|
handleGetApiKey,
|
||||||
handleRotateApiKey,
|
handleRotateApiKey,
|
||||||
@@ -74,12 +80,17 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
|||||||
import {
|
import {
|
||||||
handleCreateAccountPasskeyCredential,
|
handleCreateAccountPasskeyCredential,
|
||||||
handleDeleteAccountPasskeyCredential,
|
handleDeleteAccountPasskeyCredential,
|
||||||
|
handleDeleteTwoFactorWebAuthn,
|
||||||
handleGetAccountPasskeyAttestationOptions,
|
handleGetAccountPasskeyAttestationOptions,
|
||||||
handleGetAccountPasskeyCredentials,
|
handleGetAccountPasskeyCredentials,
|
||||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||||
|
handleGetTwoFactorWebAuthn,
|
||||||
|
handleGetTwoFactorWebAuthnChallenge,
|
||||||
|
handlePutTwoFactorWebAuthn,
|
||||||
handleUpdateAccountPasskeyEncryption,
|
handleUpdateAccountPasskeyEncryption,
|
||||||
} from './handlers/account-passkeys';
|
} from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
|
handleCreateAdminAuthRequest,
|
||||||
handleGetAuthRequest,
|
handleGetAuthRequest,
|
||||||
handleListAuthRequests,
|
handleListAuthRequests,
|
||||||
handleListPendingAuthRequests,
|
handleListPendingAuthRequests,
|
||||||
@@ -106,6 +117,40 @@ export async function handleAuthenticatedRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return unsupportedResponse('KDF changes are not supported by this server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailBackedAccountPaths = new Set([
|
||||||
|
'/api/accounts/email-token',
|
||||||
|
'/accounts/email-token',
|
||||||
|
'/api/accounts/verify-email',
|
||||||
|
'/accounts/verify-email',
|
||||||
|
'/api/accounts/verify-email-token',
|
||||||
|
'/accounts/verify-email-token',
|
||||||
|
'/api/accounts/request-otp',
|
||||||
|
'/accounts/request-otp',
|
||||||
|
'/api/accounts/verify-otp',
|
||||||
|
'/accounts/verify-otp',
|
||||||
|
]);
|
||||||
|
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return unsupportedResponse('Email delivery is not supported by this server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailTwoFactorPaths = new Set([
|
||||||
|
'/api/two-factor/get-email',
|
||||||
|
'/two-factor/get-email',
|
||||||
|
'/api/two-factor/send-email',
|
||||||
|
'/two-factor/send-email',
|
||||||
|
'/api/two-factor/send-email-login',
|
||||||
|
'/two-factor/send-email-login',
|
||||||
|
'/api/two-factor/email',
|
||||||
|
'/two-factor/email',
|
||||||
|
]);
|
||||||
|
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
|
||||||
|
return unsupportedResponse('Email two-step login is not supported by this server.');
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/profile') {
|
if (path === '/api/accounts/profile') {
|
||||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||||
@@ -141,12 +186,53 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTwoFactorAuthenticator(request, env, userId);
|
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 (path === '/api/two-factor/authenticator') {
|
||||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
return errorResponse('Method not allowed', 405);
|
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')) {
|
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||||
return handleDisableTwoFactorProvider(request, env, userId);
|
return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -294,17 +380,22 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
|
if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
|
||||||
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||||
return errorResponse('Method not allowed', 405);
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/auth-requests/pending') {
|
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
|
||||||
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||||
return errorResponse('Method not allowed', 405);
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
|
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
|
||||||
|
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
|
||||||
if (authRequestMatch) {
|
if (authRequestMatch) {
|
||||||
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
handleUpdateDeviceWebPushAuth,
|
handleUpdateDeviceWebPushAuth,
|
||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
|
handleRegisterDevice,
|
||||||
|
handleReportLostTrust,
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
|
|
||||||
function devicesPath(pattern: string): RegExp {
|
function devicesPath(pattern: string): RegExp {
|
||||||
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
if (path === '/api/devices' || path === '/devices') {
|
if (path === '/api/devices' || path === '/devices') {
|
||||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
|
if (method === 'POST') return handleRegisterDevice(request, env, userId);
|
||||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
|
||||||
|
return handleReportLostTrust(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
|
|||||||
+79
-12
@@ -1,5 +1,4 @@
|
|||||||
import { LIMITS } from './config/limits';
|
import { LIMITS } from './config/limits';
|
||||||
import { DEFAULT_DEV_SECRET } from './types';
|
|
||||||
import {
|
import {
|
||||||
handleAccessSend,
|
handleAccessSend,
|
||||||
handleAccessSendFile,
|
handleAccessSendFile,
|
||||||
@@ -8,6 +7,11 @@ import {
|
|||||||
handleDownloadSendFile,
|
handleDownloadSendFile,
|
||||||
} from './handlers/sends';
|
} from './handlers/sends';
|
||||||
import { handleKnownDevice } from './handlers/devices';
|
import { handleKnownDevice } from './handlers/devices';
|
||||||
|
import {
|
||||||
|
handleDigitalAssetLinkCheck,
|
||||||
|
handleFillAssistForms,
|
||||||
|
handleFillAssistManifest,
|
||||||
|
} from './handlers/fill-assist';
|
||||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
@@ -27,18 +31,26 @@ import {
|
|||||||
handleNotificationsNegotiate,
|
handleNotificationsNegotiate,
|
||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
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 { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
|
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||||
|
|
||||||
export interface WebBootstrapResponse {
|
export interface WebBootstrapResponse {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
registrationInviteRequired: boolean;
|
registrationInviteRequired: boolean;
|
||||||
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -96,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildConfigResponse(origin: string) {
|
function buildConfigResponse(origin: string) {
|
||||||
|
const fillAssistBase = `${origin}/fill-assist/`;
|
||||||
return {
|
return {
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
gitHash: 'nodewarden',
|
gitHash: 'nodewarden',
|
||||||
@@ -108,7 +121,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
notifications: origin + '/notifications',
|
notifications: origin + '/notifications',
|
||||||
icons: origin,
|
icons: origin,
|
||||||
sso: '',
|
sso: '',
|
||||||
fillAssistRules: null,
|
fillAssistRules: fillAssistBase,
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
pushTechnology: 0,
|
pushTechnology: 0,
|
||||||
@@ -124,8 +137,11 @@ function buildConfigResponse(origin: string) {
|
|||||||
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
|
'fill-assist-targeting-rules': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
'pm-19148-innovation-archive': true,
|
'pm-19148-innovation-archive': true,
|
||||||
|
'pm-4516-devices-add-last-activity-date': true,
|
||||||
|
'pm-30529-webauthn-related-origins': true,
|
||||||
'unauth-ui-refresh': true,
|
'unauth-ui-refresh': true,
|
||||||
'web-push': false,
|
'web-push': false,
|
||||||
},
|
},
|
||||||
@@ -241,11 +257,16 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType || 'image/png',
|
'Content-Type': contentType || 'image/png',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
'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);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
@@ -272,7 +293,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
|||||||
|
|
||||||
if (!resp.ok) continue;
|
if (!resp.ok) continue;
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (!contentType.startsWith('image/')) continue;
|
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||||
|
|
||||||
const contentLength = getPositiveContentLength(resp.headers);
|
const contentLength = getPositiveContentLength(resp.headers);
|
||||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||||
@@ -301,8 +322,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
const jwtUnsafeReason =
|
const jwtUnsafeReason =
|
||||||
!secret
|
!secret
|
||||||
? 'missing'
|
? 'missing'
|
||||||
: secret === DEFAULT_DEV_SECRET
|
|
||||||
? 'default'
|
|
||||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||||
? 'too_short'
|
? 'too_short'
|
||||||
: null;
|
: null;
|
||||||
@@ -314,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
registrationInviteRequired: userCount > 0,
|
registrationInviteRequired: userCount > 0,
|
||||||
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,12 +361,31 @@ export async function handlePublicRoute(
|
|||||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/fill-assist/manifest.json' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleFillAssistManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleDigitalAssetLinkCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||||
|
if (fillAssistFormsMatch && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleFillAssistForms(fillAssistFormsMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
@@ -395,13 +435,13 @@ export async function handlePublicRoute(
|
|||||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
|
if ((path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
return handleCreateAuthRequest(request, env);
|
return handleCreateAuthRequest(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
|
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||||
if (authRequestResponseMatch && method === 'GET') {
|
if (authRequestResponseMatch && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
@@ -448,9 +488,34 @@ export async function handlePublicRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
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);
|
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') {
|
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
@@ -467,7 +532,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const origin = new URL(request.url).origin;
|
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') {
|
if (path === '/api/version' && method === 'GET') {
|
||||||
@@ -497,6 +562,8 @@ export async function handlePublicRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
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 handleAnonymousNotificationsHub(request, env);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+91
-18
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
import { Env } from './types';
|
||||||
import { AuthService } from './services/auth';
|
import { AuthService } from './services/auth';
|
||||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||||
import { handleCors, errorResponse } from './utils/response';
|
import { handleCors, errorResponse } from './utils/response';
|
||||||
@@ -6,14 +6,25 @@ import { LIMITS } from './config/limits';
|
|||||||
import { handleAuthenticatedRoute } from './router-authenticated';
|
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||||
import { handlePublicRoute } from './router-public';
|
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();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
if (!secret) return 'missing';
|
if (!secret) return 'missing';
|
||||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
|
||||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
return null;
|
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 {
|
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||||
|
|
||||||
@@ -26,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
|
|||||||
return false;
|
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> {
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
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 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;
|
if (check.allowed) return null;
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -70,29 +148,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
return handleCors(request);
|
return handleCors(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isLargeUploadPath =
|
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
|
||||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
if (bodyLimitResult instanceof Response) {
|
||||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
return bodyLimitResult;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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);
|
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||||
if (publicResponse) return publicResponse;
|
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 auth = new AuthService(env);
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
||||||
|
|||||||
+41
-8
@@ -1,5 +1,6 @@
|
|||||||
import { Env, JWTPayload, User } from '../types';
|
import { Env, JWTPayload, User } from '../types';
|
||||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||||
|
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
|
|
||||||
// Server-side iterations for second-layer hashing.
|
// Server-side iterations for second-layer hashing.
|
||||||
@@ -28,11 +29,12 @@ export type RefreshAccessTokenFailureReason =
|
|||||||
| 'token_not_found_or_expired'
|
| 'token_not_found_or_expired'
|
||||||
| 'user_missing'
|
| 'user_missing'
|
||||||
| 'user_inactive'
|
| 'user_inactive'
|
||||||
|
| 'security_stamp_mismatch'
|
||||||
| 'device_missing'
|
| 'device_missing'
|
||||||
| 'device_session_mismatch';
|
| 'device_session_mismatch';
|
||||||
|
|
||||||
export type RefreshAccessTokenResult =
|
export type RefreshAccessTokenResult =
|
||||||
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
|
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
|
||||||
| {
|
| {
|
||||||
ok: false;
|
ok: false;
|
||||||
reason: RefreshAccessTokenFailureReason;
|
reason: RefreshAccessTokenFailureReason;
|
||||||
@@ -190,9 +192,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
async generateRefreshToken(
|
||||||
|
user: User,
|
||||||
|
device?: { identifier: string; sessionStamp: string } | null,
|
||||||
|
clientType: string = 'other'
|
||||||
|
): Promise<string> {
|
||||||
const token = createRefreshToken();
|
const token = createRefreshToken();
|
||||||
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
|
const now = Date.now();
|
||||||
|
await this.storage.saveRefreshToken(
|
||||||
|
token,
|
||||||
|
user.id,
|
||||||
|
now + getRefreshTokenSlidingTtlMs(clientType),
|
||||||
|
device?.identifier ?? null,
|
||||||
|
device?.sessionStamp ?? null,
|
||||||
|
user.securityStamp,
|
||||||
|
clientType,
|
||||||
|
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
|
||||||
|
);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,25 +267,42 @@ export class AuthService {
|
|||||||
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
let device: { identifier: string; sessionStamp: string } | null = null;
|
if (record.securityStamp && record.securityStamp !== user.securityStamp) {
|
||||||
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
|
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
|
if (!record.securityStamp) {
|
||||||
|
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
|
if (record.deviceIdentifier) {
|
||||||
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
if (!boundDevice) {
|
if (!boundDevice) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
|
if (!record.deviceSessionStamp) {
|
||||||
|
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
|
||||||
|
}
|
||||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = Math.min(
|
||||||
|
now + getRefreshTokenSlidingTtlMs(record.clientType),
|
||||||
|
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
|
||||||
|
);
|
||||||
|
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
|
||||||
|
if (!extended) {
|
||||||
|
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
const accessToken = await this.generateAccessToken(user, device);
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
return { ok: true, accessToken, user, device };
|
return { ok: true, accessToken, user, device, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAccessToken(
|
async refreshAccessToken(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zipSync, unzipSync } from 'fflate';
|
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { APP_VERSION } from '../../shared/app-version';
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
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.
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
const BACKUP_JSON_INDENT = 2;
|
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_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||||
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||||
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
|
||||||
|
|
||||||
export interface BackupManifest {
|
export interface BackupManifest {
|
||||||
formatVersion: 1;
|
formatVersion: 1;
|
||||||
@@ -186,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSafeBackupPathSegment(value: string): boolean {
|
||||||
|
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
|
||||||
|
if (value === '.' || value === '..') return false;
|
||||||
|
return /^[A-Za-z0-9._-]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeBackupAttachmentEntryName(value: string): boolean {
|
||||||
|
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
|
||||||
|
const relative = value.slice('attachments/'.length, -'.bin'.length);
|
||||||
|
return isSafeBackupAttachmentBlobName(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBackupEntryName(name: string): void {
|
||||||
|
const normalized = String(name || '').trim();
|
||||||
|
if (normalized !== name || !normalized) {
|
||||||
|
throw new Error('Backup archive contains an invalid file name');
|
||||||
|
}
|
||||||
|
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
|
||||||
|
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
|
||||||
|
}
|
||||||
|
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
|
||||||
|
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
|
||||||
|
let entryCount = 0;
|
||||||
|
let totalOriginalBytes = 0;
|
||||||
|
return (file: UnzipFileInfo): boolean => {
|
||||||
|
entryCount += 1;
|
||||||
|
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||||
|
throw new Error('Backup archive contains too many files');
|
||||||
|
}
|
||||||
|
validateBackupEntryName(file.name);
|
||||||
|
const originalSize = Number(file.originalSize);
|
||||||
|
if (!Number.isFinite(originalSize) || originalSize < 0) {
|
||||||
|
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
|
||||||
|
}
|
||||||
|
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
|
throw new Error('Backup archive database payload is too large');
|
||||||
|
}
|
||||||
|
totalOriginalBytes += originalSize;
|
||||||
|
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||||
|
throw new Error('Backup archive expands beyond the current restore limit');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
for (const row of db.attachments) {
|
for (const row of db.attachments) {
|
||||||
@@ -223,8 +279,11 @@ export function parseBackupArchive(
|
|||||||
validateArchiveSize(bytes);
|
validateArchiveSize(bytes);
|
||||||
let zipped: Record<string, Uint8Array>;
|
let zipped: Record<string, Uint8Array>;
|
||||||
try {
|
try {
|
||||||
zipped = unzipSync(bytes);
|
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error('Invalid backup archive');
|
throw new Error('Invalid backup archive');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +294,7 @@ export function parseBackupArchive(
|
|||||||
|
|
||||||
let totalExtractedBytes = 0;
|
let totalExtractedBytes = 0;
|
||||||
for (const entry of entryNames) {
|
for (const entry of entryNames) {
|
||||||
|
validateBackupEntryName(entry);
|
||||||
const entryBytes = zipped[entry];
|
const entryBytes = zipped[entry];
|
||||||
totalExtractedBytes += entryBytes.byteLength;
|
totalExtractedBytes += entryBytes.byteLength;
|
||||||
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of attachmentRows) {
|
for (const row of attachmentRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const cipherId = String(row.cipher_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');
|
throw new Error('Backup archive contains an invalid attachment row');
|
||||||
}
|
}
|
||||||
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
||||||
@@ -382,9 +442,10 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of accountPasskeyRows) {
|
for (const row of accountPasskeyRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const userId = String(row.user_id || '').trim();
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
|
||||||
const credentialId = String(row.credential_id || '').trim();
|
const credentialId = String(row.credential_id || '').trim();
|
||||||
const publicKey = String(row.public_key || '').trim();
|
const publicKey = String(row.public_key || '').trim();
|
||||||
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
|
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
|
||||||
throw new Error('Backup archive contains an invalid account passkey row');
|
throw new Error('Backup archive contains an invalid account passkey row');
|
||||||
}
|
}
|
||||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||||
@@ -427,13 +488,13 @@ export async function buildBackupArchive(
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = 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 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, 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 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, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
|
|||||||
+296
-17
@@ -26,7 +26,9 @@ import {
|
|||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
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 BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
|
export const REDACTED_BACKUP_SECRET = '********';
|
||||||
const MAX_BACKUP_DESTINATIONS = 24;
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -66,6 +68,163 @@ function normalizePath(value: unknown): string {
|
|||||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHostnameForPolicy(hostname: string): string {
|
||||||
|
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4Address(hostname: string): number[] | null {
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const octets = parts.map((part) => {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||||
|
const value = Number(part);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||||
|
});
|
||||||
|
return octets.every((value) => value >= 0) ? octets : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||||
|
const [a, b, c] = octets;
|
||||||
|
return (
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && (b === 0 || b === 168)) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||||
|
(a === 203 && b === 0 && c === 113) ||
|
||||||
|
a >= 224
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
|
||||||
|
* Needed so compressed forms like "::1" are not misclassified by a naive
|
||||||
|
* "first non-empty hextet" check (which would read "1" and miss loopback).
|
||||||
|
*/
|
||||||
|
function expandIpv6Address(hostname: string): string[] | null {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (!normalized.includes(':')) return null;
|
||||||
|
if (normalized.includes('.')) {
|
||||||
|
// IPv4-embedded forms are handled separately by the caller.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((normalized.match(/::/g) || []).length > 1) return null;
|
||||||
|
|
||||||
|
const sides = normalized.split('::');
|
||||||
|
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
|
||||||
|
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
|
||||||
|
if (left.length + right.length > 8) return null;
|
||||||
|
if (sides.length === 1 && left.length !== 8) return null;
|
||||||
|
|
||||||
|
const missing = 8 - left.length - right.length;
|
||||||
|
if (sides.length > 1 && missing < 0) return null;
|
||||||
|
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
|
||||||
|
const parts = [...left, ...middle, ...right];
|
||||||
|
if (parts.length !== 8) return null;
|
||||||
|
|
||||||
|
const hextets: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
|
||||||
|
hextets.push(part.padStart(4, '0'));
|
||||||
|
}
|
||||||
|
return hextets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6Address(hostname: string): boolean {
|
||||||
|
if (!hostname.includes(':')) return false;
|
||||||
|
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
|
// IPv4-mapped dotted form: ::ffff:127.0.0.1
|
||||||
|
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
|
||||||
|
if (mappedIpv4) {
|
||||||
|
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||||
|
return !octets || isBlockedIpv4Address(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
|
||||||
|
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
||||||
|
if (mappedHex) {
|
||||||
|
const hi = Number.parseInt(mappedHex[1], 16);
|
||||||
|
const lo = Number.parseInt(mappedHex[2], 16);
|
||||||
|
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
|
||||||
|
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
|
||||||
|
return isBlockedIpv4Address(octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hextets = expandIpv6Address(normalized);
|
||||||
|
if (!hextets) return true;
|
||||||
|
const firstHextet = Number.parseInt(hextets[0], 16);
|
||||||
|
if (!Number.isFinite(firstHextet)) return true;
|
||||||
|
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
|
||||||
|
return (
|
||||||
|
firstHextet === 0 ||
|
||||||
|
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||||
|
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||||
|
(firstHextet & 0xff00) === 0xff00 ||
|
||||||
|
hextets.join(':').startsWith('2001:0db8:')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||||
|
const normalized = normalizeHostnameForPolicy(hostname);
|
||||||
|
if (!normalized) throw new Error(`${label} host is required`);
|
||||||
|
if (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized === 'localhost.localdomain' ||
|
||||||
|
normalized.endsWith('.localhost.localdomain') ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized.endsWith('.home.arpa') ||
|
||||||
|
normalized.endsWith('.internal') ||
|
||||||
|
normalized.endsWith('.lan') ||
|
||||||
|
normalized === 'metadata.google.internal' ||
|
||||||
|
normalized === 'localtest.me' ||
|
||||||
|
normalized.endsWith('.localtest.me') ||
|
||||||
|
normalized === 'lvh.me' ||
|
||||||
|
normalized.endsWith('.lvh.me') ||
|
||||||
|
normalized === 'vcap.me' ||
|
||||||
|
normalized.endsWith('.vcap.me') ||
|
||||||
|
normalized === 'nip.io' ||
|
||||||
|
normalized.endsWith('.nip.io') ||
|
||||||
|
normalized === 'sslip.io' ||
|
||||||
|
normalized.endsWith('.sslip.io') ||
|
||||||
|
normalized === 'xip.io' ||
|
||||||
|
normalized.endsWith('.xip.io')
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
const ipv4 = parseIpv4Address(normalized);
|
||||||
|
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
if (isBlockedIpv6Address(normalized)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} must be a valid URL`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`${label} must start with http:// or https://`);
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
throw new Error(`${label} must not include credentials`);
|
||||||
|
}
|
||||||
|
if (parsed.search || parsed.hash) {
|
||||||
|
throw new Error(`${label} must not include query or fragment`);
|
||||||
|
}
|
||||||
|
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||||
|
return parsed.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function assertValidTimeZone(timezone: string): string {
|
function assertValidTimeZone(timezone: string): string {
|
||||||
try {
|
try {
|
||||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
@@ -121,7 +280,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
|
|
||||||
if (!allowIncomplete || endpoint) {
|
if (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || bucket) {
|
if (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('S3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
@@ -134,7 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||||
bucket,
|
bucket,
|
||||||
addressingStyle,
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
@@ -153,7 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
|
|
||||||
if (!allowIncomplete || baseUrl) {
|
if (!allowIncomplete || baseUrl) {
|
||||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || username) {
|
if (!allowIncomplete || username) {
|
||||||
if (!username) throw new Error('WebDAV username is required');
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
@@ -163,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
remotePath,
|
remotePath,
|
||||||
@@ -179,6 +338,32 @@ function normalizeDestination(
|
|||||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
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 {
|
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const asIso = (input: unknown): string | null => {
|
const asIso = (input: unknown): string | null => {
|
||||||
@@ -249,7 +434,11 @@ function normalizeDestinationRecord(
|
|||||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -324,6 +513,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
|
|||||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
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 {
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
}
|
}
|
||||||
@@ -387,27 +617,55 @@ export function normalizeBackupSettingsInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
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> {
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||||
|
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||||
|
);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = parseBackupSettingsEnvelope(raw);
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Backup settings need administrator reactivation after restore');
|
throw new Error('Backup settings need administrator reactivation after restore');
|
||||||
}
|
}
|
||||||
@@ -417,6 +675,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
|||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
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> {
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
@@ -596,9 +875,9 @@ export function hasBackupSlotBetween(
|
|||||||
const endMs = endExclusive.getTime();
|
const endMs = endExclusive.getTime();
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||||
|
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
const dayCursor = new Date(startMs);
|
const dayCursor = new Date(startMs);
|
||||||
@@ -620,7 +899,7 @@ export function hasBackupSlotBetween(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) continue;
|
if (lastSuccessMs >= slotStartMs) continue;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,9 +916,9 @@ export function isBackupDueNow(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
@@ -652,7 +931,7 @@ export function isBackupDueNow(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) return false;
|
if (lastSuccessMs >= slotStartMs) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
|
|||||||
import {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
|
isSafeBackupAttachmentBlobName,
|
||||||
parseBackupArchive,
|
parseBackupArchive,
|
||||||
validateBackupPayloadContents,
|
validateBackupPayloadContents,
|
||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
@@ -253,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
|
|||||||
return rows.map((row) => ({ ...row }));
|
return rows.map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
|
||||||
|
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
|
||||||
|
}
|
||||||
|
|
||||||
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
const nextRows = rows.map((row) => {
|
const nextRows = rows.map((row) => {
|
||||||
@@ -296,12 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
users: cloneRows(payload.users || []).map((row) => ({
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
verify_devices: row.verify_devices ?? 1,
|
verify_devices: row.verify_devices ?? 0,
|
||||||
|
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||||
|
})),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -461,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
||||||
return new Map(
|
const lookup = new Map<string, BackupManifestAttachmentBlob>();
|
||||||
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
|
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(
|
async function prepareRemoteAttachmentPayload(
|
||||||
@@ -619,7 +639,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('users'),
|
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 || []
|
payload.users || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -655,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||||
payload.webauthn_credentials || []
|
payload.webauthn_credentials || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
|
normalizeBackupEndpointUrl,
|
||||||
} from './backup-config';
|
} from './backup-config';
|
||||||
|
|
||||||
export interface BackupUploadResult {
|
export interface BackupUploadResult {
|
||||||
@@ -33,6 +34,13 @@ export interface RemoteBackupFile {
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFileStat {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackupFilePutOptions {
|
export interface RemoteBackupFilePutOptions {
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
}
|
}
|
||||||
@@ -208,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 'webdav') {
|
if (destination.type === 'webdav') {
|
||||||
const config = destination.destination as WebDavBackupDestination;
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
|
||||||
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
return;
|
return;
|
||||||
@@ -216,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as S3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
|
||||||
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||||
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
||||||
@@ -245,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
if ([200, 201, 204, 405].includes(response.status)) continue;
|
||||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +276,7 @@ async function ensureWebDavDirectoryCached(
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
if ([200, 201, 204, 405].includes(response.status)) {
|
||||||
ensuredDirectories.add(current);
|
ensuredDirectories.add(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -433,6 +441,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
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 authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, relativePath);
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
@@ -441,11 +453,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
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 {
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
@@ -501,7 +519,7 @@ async function signedS3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
@@ -540,10 +558,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
let continuationToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
const url = s3BucketBaseUrl(config);
|
const url = s3BucketBaseUrl(config);
|
||||||
url.searchParams.set('list-type', '2');
|
url.searchParams.set('list-type', '2');
|
||||||
url.searchParams.set('delimiter', '/');
|
url.searchParams.set('delimiter', '/');
|
||||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||||
|
|
||||||
const response = await signedS3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -551,8 +575,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const rootPrefix = trimSlashes(config.rootPath);
|
|
||||||
const items: RemoteBackupItem[] = [];
|
|
||||||
|
|
||||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||||
@@ -596,6 +618,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
const deduped = new Map<string, RemoteBackupItem>();
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
|
|
||||||
@@ -637,14 +662,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
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 objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = s3ObjectUrl(config, objectKey);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
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 {
|
interface ConfiguredDestinationAdapter {
|
||||||
@@ -656,6 +691,7 @@ interface ConfiguredDestinationAdapter {
|
|||||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
|
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteBackupTransferSession {
|
export interface RemoteBackupTransferSession {
|
||||||
@@ -666,6 +702,7 @@ export interface RemoteBackupTransferSession {
|
|||||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
deleteFile(relativePath: string): Promise<void>;
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
exists(relativePath: string): Promise<boolean>;
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfiguredDestinationAdapter(
|
function resolveConfiguredDestinationAdapter(
|
||||||
@@ -683,6 +720,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
@@ -695,6 +733,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +769,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
|||||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
||||||
'cache-control': 'no-cache',
|
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
origin: 'https://bitwarden.com',
|
|
||||||
pragma: 'no-cache',
|
|
||||||
priority: 'u=1, i',
|
|
||||||
referer: 'https://bitwarden.com/host/',
|
|
||||||
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
|
||||||
'sec-ch-ua-mobile': '?0',
|
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
|
||||||
'sec-fetch-dest': 'empty',
|
|
||||||
'sec-fetch-mode': 'cors',
|
|
||||||
'sec-fetch-site': 'same-site',
|
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
formName: 'request_host',
|
|
||||||
url: '/host/',
|
|
||||||
locale: 'zh-CN',
|
|
||||||
email: randomInstallationEmail(),
|
email: randomInstallationEmail(),
|
||||||
region: 'us',
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'Failed to request Bitwarden push installation:'
|
'Failed to request Bitwarden push installation:'
|
||||||
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | 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 || '').trim();
|
const id = String(body?.id || body?.Id || '').trim();
|
||||||
const key = String(body?.key || '').trim();
|
const key = String(body?.key || body?.Key || '').trim();
|
||||||
if (!id || !key) {
|
if (!id || !key) {
|
||||||
console.error('Bitwarden push installation response did not include id/key');
|
console.error('Bitwarden push installation response did not include id/key');
|
||||||
return null;
|
return null;
|
||||||
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
|
|||||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||||
const normalized = String(pushUuid || '').trim();
|
const normalized = String(pushUuid || '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
|
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyMobilePush(
|
export async function notifyMobilePush(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
// Rate limiting service.
|
// Rate limiting service.
|
||||||
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||||
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
// - 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 = {
|
const CONFIG = {
|
||||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
@@ -12,11 +13,14 @@ const CONFIG = {
|
|||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
private static loginIpTableReady = false;
|
private static loginIpTableReady = false;
|
||||||
|
private static strictBudgetTableReady = false;
|
||||||
private static lastLoginIpCleanupAt = 0;
|
private static lastLoginIpCleanupAt = 0;
|
||||||
|
private static lastStrictBudgetCleanupAt = 0;
|
||||||
|
|
||||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
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_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
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) {}
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
@@ -58,6 +62,35 @@ export class RateLimitService {
|
|||||||
RateLimitService.loginIpTableReady = true;
|
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<{
|
async checkLoginAttempt(ip: string): Promise<{
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
remainingAttempts: number;
|
remainingAttempts: number;
|
||||||
@@ -174,6 +207,59 @@ export class RateLimitService {
|
|||||||
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
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.
|
// General-purpose fixed-window budget.
|
||||||
// Callers supply an identifier (must be unique per rate-limit category) and the
|
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||||
// per-window maximum. This single method replaces all previous specialised
|
// per-window maximum. This single method replaces all previous specialised
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
|
|||||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||||
{ name: 'id', sql: 'id TEXT' },
|
{ name: 'id', sql: 'id TEXT' },
|
||||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
{ 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: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||||
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
'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, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||||
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
|
|||||||
function mapCredentialRow(row: {
|
function mapCredentialRow(row: {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
purpose?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
credential_id: string;
|
credential_id: string;
|
||||||
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
|
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||||
name: row.name,
|
name: row.name,
|
||||||
publicKey: row.public_key,
|
publicKey: row.public_key,
|
||||||
credentialId: row.credential_id,
|
credentialId: row.credential_id,
|
||||||
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
|
|||||||
await safeBind(
|
await safeBind(
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT INTO webauthn_credentials(' +
|
'INSERT INTO webauthn_credentials(' +
|
||||||
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||||
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
'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'
|
'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.id,
|
||||||
credential.userId,
|
credential.userId,
|
||||||
|
credential.purpose,
|
||||||
credential.name,
|
credential.name,
|
||||||
credential.publicKey,
|
credential.publicKey,
|
||||||
credential.credentialId,
|
credential.credentialId,
|
||||||
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
|
|||||||
|
|
||||||
export async function listAccountPasskeyCredentialsByUserId(
|
export async function listAccountPasskeyCredentialsByUserId(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<AccountPasskeyCredential[]> {
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||||
.bind(userId)
|
.bind(userId, purpose)
|
||||||
.all<any>();
|
.all<any>();
|
||||||
return (rows.results || []).map(mapCredentialRow);
|
return (rows.results || []).map(mapCredentialRow);
|
||||||
}
|
}
|
||||||
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
|
|||||||
|
|
||||||
export async function countAccountPasskeyCredentialsByUserId(
|
export async function countAccountPasskeyCredentialsByUserId(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||||
.bind(userId)
|
.bind(userId, purpose)
|
||||||
.first<{ count: number }>();
|
.first<{ count: number }>();
|
||||||
return Number(row?.count || 0);
|
return Number(row?.count || 0);
|
||||||
}
|
}
|
||||||
@@ -262,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
||||||
'WHERE user_id = ? AND credential_id = ?'
|
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
|
||||||
)
|
)
|
||||||
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||||
.run();
|
.run();
|
||||||
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
|
|||||||
export async function deleteAccountPasskeyCredential(
|
export async function deleteAccountPasskeyCredential(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
id: string
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
await ensureAccountPasskeySchema(db);
|
await ensureAccountPasskeySchema(db);
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||||
.bind(userId, id)
|
.bind(userId, id, purpose)
|
||||||
.run();
|
.run();
|
||||||
return Number(result.meta.changes || 0) > 0;
|
return Number(result.meta.changes || 0) > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
void userId;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.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();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
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 now = new Date().toISOString();
|
||||||
const result = await db
|
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)
|
.bind(now, code)
|
||||||
.run();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
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> {
|
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||||
const result = await db.prepare('DELETE FROM invites').run();
|
const result = await db.prepare('DELETE FROM invites').run();
|
||||||
return Number(result.meta.changes ?? 0);
|
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> {
|
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
'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();
|
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();
|
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(
|
export async function bulkDeleteAttachmentsByIds(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
sqlChunkSize: SqlChunkSize,
|
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();
|
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> {
|
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
|
|||||||
return row ? mapAuthRequestRow(row) : null;
|
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[]> {
|
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>();
|
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);
|
return (res.results || []).map(mapAuthRequestRow);
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
|||||||
return parseCipherRow(row);
|
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> {
|
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||||
const folderId = normalizeOptionalId(cipher.folderId);
|
const folderId = normalizeOptionalId(cipher.folderId);
|
||||||
const data = buildCipherData(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) ' +
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'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(
|
await safeBind(
|
||||||
stmt,
|
stmt,
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export async function upsertDevice(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
|
||||||
|
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
|
||||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
@@ -66,7 +67,8 @@ export async function upsertDevice(
|
|||||||
existingDevice?.deviceNote ?? null,
|
existingDevice?.deviceNote ?? null,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
now
|
now,
|
||||||
|
''
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,20 @@ export async function touchDeviceLastSeen(
|
|||||||
return Number(result.meta.changes ?? 0) > 0;
|
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(
|
export async function updateDeviceKeys(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
|
|||||||
return mapFolderRow(row);
|
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> {
|
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
'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)
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||||
.run();
|
.run();
|
||||||
@@ -44,9 +53,14 @@ export async function clearFolderFromCiphers(
|
|||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = NULL, updated_at = ?,
|
SET folder_id = NULL, updated_at = ?,
|
||||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
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();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +85,14 @@ export async function bulkDeleteFolders(
|
|||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = NULL, updated_at = ?,
|
SET folder_id = NULL, updated_at = ?,
|
||||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
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();
|
.run();
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
|
|||||||
userId: string,
|
userId: string,
|
||||||
expiresAtMs: number,
|
expiresAtMs: number,
|
||||||
deviceIdentifier?: string | null,
|
deviceIdentifier?: string | null,
|
||||||
deviceSessionStamp?: string | null
|
deviceSessionStamp?: string | null,
|
||||||
|
securityStamp?: string | null,
|
||||||
|
clientType?: string | null,
|
||||||
|
absoluteExpiresAtMs?: number | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await maybeCleanupExpiredRefreshTokens(Date.now());
|
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||||
const tokenKey = await refreshTokenKey(token);
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
const now = Date.now();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
|
||||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
|
||||||
|
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
|
||||||
|
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
tokenKey,
|
||||||
|
userId,
|
||||||
|
expiresAtMs,
|
||||||
|
deviceIdentifier ?? null,
|
||||||
|
deviceSessionStamp ?? null,
|
||||||
|
securityStamp ?? null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
absoluteExpiresAtMs ?? null,
|
||||||
|
clientType ?? null
|
||||||
)
|
)
|
||||||
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
|
|||||||
const tokenKey = await refreshTokenKey(token);
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
.prepare(
|
||||||
|
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
|
||||||
|
'FROM refresh_tokens WHERE token = ?'
|
||||||
|
)
|
||||||
.bind(tokenKey)
|
.bind(tokenKey)
|
||||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
.first<{
|
||||||
|
user_id: string;
|
||||||
|
expires_at: number;
|
||||||
|
device_identifier: string | null;
|
||||||
|
device_session_stamp: string | null;
|
||||||
|
security_stamp: string | null;
|
||||||
|
created_at: number | null;
|
||||||
|
last_used_at: number | null;
|
||||||
|
absolute_expires_at: number | null;
|
||||||
|
client_type: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.expires_at && row.expires_at < now) {
|
if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
|
||||||
await deleteRefreshTokenRecord(token);
|
await deleteRefreshTokenRecord(token);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -50,9 +81,62 @@ export async function getRefreshTokenRecord(
|
|||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
deviceIdentifier: row.device_identifier ?? null,
|
deviceIdentifier: row.device_identifier ?? null,
|
||||||
deviceSessionStamp: row.device_session_stamp ?? null,
|
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||||
|
securityStamp: row.security_stamp ?? null,
|
||||||
|
createdAt: row.created_at ?? null,
|
||||||
|
lastUsedAt: row.last_used_at ?? null,
|
||||||
|
absoluteExpiresAt: row.absolute_expires_at ?? null,
|
||||||
|
clientType: row.client_type ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function extendRefreshTokenExpiry(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
requestedExpiresAtMs: number,
|
||||||
|
nowMs: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE refresh_tokens SET ' +
|
||||||
|
'expires_at = CASE ' +
|
||||||
|
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
|
||||||
|
'ELSE ? END, ' +
|
||||||
|
'last_used_at = ? ' +
|
||||||
|
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
|
||||||
|
)
|
||||||
|
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindRefreshTokenSecurityStamp(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
securityStamp: string
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
|
||||||
|
.bind(securityStamp, tokenKey, '')
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bindRefreshTokenDeviceStamp(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
deviceSessionStamp: string
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
|
||||||
|
.bind(deviceSessionStamp, tokenKey, '')
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
||||||
const tokenKey = await refreshTokenKey(token);
|
const tokenKey = await refreshTokenKey(token);
|
||||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
@@ -71,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
|
|||||||
.run();
|
.run();
|
||||||
return Number(result.meta.changes ?? 0);
|
return Number(result.meta.changes ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function constrainRefreshTokenExpiry(
|
|
||||||
db: D1Database,
|
|
||||||
refreshTokenKey: RefreshTokenKeyFn,
|
|
||||||
token: string,
|
|
||||||
maxExpiresAtMs: number
|
|
||||||
): Promise<void> {
|
|
||||||
const tokenKey = await refreshTokenKey(token);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
'UPDATE refresh_tokens ' +
|
|
||||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
|
||||||
'WHERE token = ?'
|
|
||||||
)
|
|
||||||
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
'UPDATE refresh_tokens ' +
|
|
||||||
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
|
||||||
'WHERE token = ?'
|
|
||||||
)
|
|
||||||
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,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, ' +
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN yubikey_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',
|
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||||
@@ -68,16 +74,26 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||||
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||||
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
|
||||||
|
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
|
||||||
|
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
|
||||||
|
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
|
||||||
|
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS invites (' +
|
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||||
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||||
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
'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_status_expires ON invites(status, expires_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||||
|
|
||||||
@@ -111,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||||
|
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
|
||||||
|
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||||
@@ -126,11 +144,18 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
'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 (' +
|
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
'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, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 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 ON webauthn_credentials(user_id)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||||
|
|||||||
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
|
|||||||
return mapSendRow(row);
|
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> {
|
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||||
const stmt = db.prepare(
|
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) ' +
|
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'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, ' +
|
'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, ' +
|
'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(
|
await safeBind(
|
||||||
@@ -81,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
|
|||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
'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();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
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 =
|
const USER_SELECT_COLUMNS =
|
||||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
'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, ' +
|
'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 {
|
function mapUserRow(row: any): User {
|
||||||
return {
|
return {
|
||||||
@@ -23,9 +23,15 @@ function mapUserRow(row: any): User {
|
|||||||
securityStamp: row.security_stamp,
|
securityStamp: row.security_stamp,
|
||||||
role: row.role === 'admin' ? 'admin' : 'user',
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
status: row.status === 'banned' ? 'banned' : 'active',
|
status: row.status === 'banned' ? 'banned' : 'active',
|
||||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
|
||||||
totpSecret: row.totp_secret ?? null,
|
totpSecret: row.totp_secret ?? null,
|
||||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
|
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||||
|
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,
|
apiKey: row.api_key ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_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> {
|
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
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) ' +
|
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'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, ' +
|
'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(
|
await safeBind(
|
||||||
stmt,
|
stmt,
|
||||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.yubikeyKey1,
|
||||||
|
user.yubikeyKey2,
|
||||||
|
user.yubikeyKey3,
|
||||||
|
user.yubikeyKey4,
|
||||||
|
user.yubikeyKey5,
|
||||||
|
user.yubikeyNfc ? 1 : 0,
|
||||||
user.apiKey,
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
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> {
|
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
const stmt = db.prepare(
|
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) ' +
|
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||||
);
|
);
|
||||||
const result = await safeBind(
|
const result = await safeBind(
|
||||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
|||||||
user.verifyDevices ? 1 : 0,
|
user.verifyDevices ? 1 : 0,
|
||||||
user.totpSecret,
|
user.totpSecret,
|
||||||
user.totpRecoveryCode,
|
user.totpRecoveryCode,
|
||||||
|
user.yubikeyKey1,
|
||||||
|
user.yubikeyKey2,
|
||||||
|
user.yubikeyKey3,
|
||||||
|
user.yubikeyKey4,
|
||||||
|
user.yubikeyKey5,
|
||||||
|
user.yubikeyNfc ? 1 : 0,
|
||||||
user.apiKey,
|
user.apiKey,
|
||||||
user.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
|
|||||||
+123
-20
@@ -22,7 +22,10 @@ import {
|
|||||||
type AuditLogListOptions,
|
type AuditLogListOptions,
|
||||||
createAuditLog as createStoredAuditLog,
|
createAuditLog as createStoredAuditLog,
|
||||||
clearAuditLogs as clearStoredAuditLogs,
|
clearAuditLogs as clearStoredAuditLogs,
|
||||||
|
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||||
createInvite as createStoredInvite,
|
createInvite as createStoredInvite,
|
||||||
|
deleteInvite as deleteStoredInvite,
|
||||||
|
deleteInvalidInvites as deleteStoredInvalidInvites,
|
||||||
deleteAllInvites as deleteStoredInvites,
|
deleteAllInvites as deleteStoredInvites,
|
||||||
getInvite as findStoredInvite,
|
getInvite as findStoredInvite,
|
||||||
listAuditLogs as listStoredAuditLogs,
|
listAuditLogs as listStoredAuditLogs,
|
||||||
@@ -30,7 +33,7 @@ import {
|
|||||||
markInviteUsed as markStoredInviteUsed,
|
markInviteUsed as markStoredInviteUsed,
|
||||||
pruneAuditLogs as pruneStoredAuditLogs,
|
pruneAuditLogs as pruneStoredAuditLogs,
|
||||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||||
revokeInvite as revokeStoredInvite,
|
revertInviteUsed as revertStoredInviteUsed,
|
||||||
} from './storage-admin-repo';
|
} from './storage-admin-repo';
|
||||||
import {
|
import {
|
||||||
bulkDeleteFolders as deleteStoredFolders,
|
bulkDeleteFolders as deleteStoredFolders,
|
||||||
@@ -38,6 +41,7 @@ import {
|
|||||||
deleteFolder as deleteStoredFolder,
|
deleteFolder as deleteStoredFolder,
|
||||||
getAllFolders as listStoredFolders,
|
getAllFolders as listStoredFolders,
|
||||||
getFolder as findStoredFolder,
|
getFolder as findStoredFolder,
|
||||||
|
getFolderForUser as findStoredFolderForUser,
|
||||||
getFoldersPage as listStoredFoldersPage,
|
getFoldersPage as listStoredFoldersPage,
|
||||||
saveFolder as saveStoredFolder,
|
saveFolder as saveStoredFolder,
|
||||||
} from './storage-folder-repo';
|
} from './storage-folder-repo';
|
||||||
@@ -50,6 +54,7 @@ import {
|
|||||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||||
getAllCiphers as listStoredCiphers,
|
getAllCiphers as listStoredCiphers,
|
||||||
getCipher as findStoredCipher,
|
getCipher as findStoredCipher,
|
||||||
|
getCipherForUser as findStoredCipherForUser,
|
||||||
getCiphersByIds as listStoredCiphersByIds,
|
getCiphersByIds as listStoredCiphersByIds,
|
||||||
getCiphersPage as listStoredCiphersPage,
|
getCiphersPage as listStoredCiphersPage,
|
||||||
saveCipher as saveStoredCipher,
|
saveCipher as saveStoredCipher,
|
||||||
@@ -57,10 +62,13 @@ import {
|
|||||||
} from './storage-cipher-repo';
|
} from './storage-cipher-repo';
|
||||||
import {
|
import {
|
||||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||||
|
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
|
||||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||||
deleteAttachment as deleteStoredAttachment,
|
deleteAttachment as deleteStoredAttachment,
|
||||||
|
deleteAttachmentForUser as deleteStoredAttachmentForUser,
|
||||||
getAttachment as findStoredAttachment,
|
getAttachment as findStoredAttachment,
|
||||||
|
getAttachmentForUser as findStoredAttachmentForUser,
|
||||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||||
@@ -72,16 +80,19 @@ import {
|
|||||||
deleteSend as deleteStoredSend,
|
deleteSend as deleteStoredSend,
|
||||||
getAllSends as listStoredSends,
|
getAllSends as listStoredSends,
|
||||||
getSend as findStoredSend,
|
getSend as findStoredSend,
|
||||||
|
getSendForUser as findStoredSendForUser,
|
||||||
getSendsByIds as listStoredSendsByIds,
|
getSendsByIds as listStoredSendsByIds,
|
||||||
getSendsPage as listStoredSendsPage,
|
getSendsPage as listStoredSendsPage,
|
||||||
incrementSendAccessCount as incrementStoredSendAccessCount,
|
incrementSendAccessCount as incrementStoredSendAccessCount,
|
||||||
saveSend as saveStoredSend,
|
saveSend as saveStoredSend,
|
||||||
} from './storage-send-repo';
|
} from './storage-send-repo';
|
||||||
import {
|
import {
|
||||||
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry,
|
bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
|
||||||
|
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
|
||||||
deleteRefreshToken as deleteStoredRefreshToken,
|
deleteRefreshToken as deleteStoredRefreshToken,
|
||||||
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
|
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
|
||||||
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
|
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
|
||||||
|
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
|
||||||
getRefreshTokenRecord as findStoredRefreshTokenRecord,
|
getRefreshTokenRecord as findStoredRefreshTokenRecord,
|
||||||
saveRefreshToken as saveStoredRefreshToken,
|
saveRefreshToken as saveStoredRefreshToken,
|
||||||
} from './storage-refresh-token-repo';
|
} from './storage-refresh-token-repo';
|
||||||
@@ -100,6 +111,7 @@ import {
|
|||||||
isKnownDevice as getKnownStoredDevice,
|
isKnownDevice as getKnownStoredDevice,
|
||||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||||
|
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
|
||||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
updateDeviceName as updateStoredDeviceName,
|
updateDeviceName as updateStoredDeviceName,
|
||||||
@@ -111,6 +123,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createAuthRequest as createStoredAuthRequest,
|
createAuthRequest as createStoredAuthRequest,
|
||||||
getAuthRequestById as findStoredAuthRequestById,
|
getAuthRequestById as findStoredAuthRequestById,
|
||||||
|
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
|
||||||
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||||
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||||
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||||
@@ -121,6 +134,9 @@ import {
|
|||||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||||
} from './storage-attachment-token-repo';
|
} from './storage-attachment-token-repo';
|
||||||
|
import {
|
||||||
|
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
|
||||||
|
} from './storage-totp-replay-repo';
|
||||||
import {
|
import {
|
||||||
getRevisionDate as getStoredRevisionDate,
|
getRevisionDate as getStoredRevisionDate,
|
||||||
updateRevisionDate as updateStoredRevisionDate,
|
updateRevisionDate as updateStoredRevisionDate,
|
||||||
@@ -148,8 +164,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
|||||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// differs from config.schema.version.
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
|
const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
|
||||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -162,10 +178,13 @@ export class StorageService {
|
|||||||
private static schemaVerified = false;
|
private static schemaVerified = false;
|
||||||
private static lastRefreshTokenCleanupAt = 0;
|
private static lastRefreshTokenCleanupAt = 0;
|
||||||
private static lastAttachmentTokenCleanupAt = 0;
|
private static lastAttachmentTokenCleanupAt = 0;
|
||||||
|
private static lastTotpReplayCleanupAt = 0;
|
||||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||||
|
|
||||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
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 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;
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||||
|
|
||||||
constructor(private db: D1Database) {}
|
constructor(private db: D1Database) {}
|
||||||
@@ -313,8 +332,20 @@ export class StorageService {
|
|||||||
return markStoredInviteUsed(this.db, code, userId);
|
return markStoredInviteUsed(this.db, code, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeInvite(code: string): Promise<boolean> {
|
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
|
||||||
return revokeStoredInvite(this.db, code);
|
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> {
|
async deleteAllInvites(): Promise<number> {
|
||||||
@@ -370,8 +401,11 @@ export class StorageService {
|
|||||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
async getAccountPasskeyCredentialsByUserId(
|
||||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
|
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||||
@@ -382,8 +416,11 @@ export class StorageService {
|
|||||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
async countAccountPasskeyCredentialsByUserId(
|
||||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<number> {
|
||||||
|
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAccountPasskeyCounter(
|
async updateAccountPasskeyCounter(
|
||||||
@@ -414,8 +451,12 @@ export class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
async deleteAccountPasskeyCredential(
|
||||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<boolean> {
|
||||||
|
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||||
@@ -437,6 +478,10 @@ export class StorageService {
|
|||||||
return findStoredCipher(this.db, id);
|
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> {
|
async saveCipher(cipher: Cipher): Promise<void> {
|
||||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||||
}
|
}
|
||||||
@@ -487,6 +532,10 @@ export class StorageService {
|
|||||||
return findStoredFolder(this.db, id);
|
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> {
|
async saveFolder(folder: Folder): Promise<void> {
|
||||||
await saveStoredFolder(this.db, folder);
|
await saveStoredFolder(this.db, folder);
|
||||||
}
|
}
|
||||||
@@ -525,6 +574,10 @@ export class StorageService {
|
|||||||
return findStoredAttachment(this.db, id);
|
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> {
|
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||||
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
||||||
}
|
}
|
||||||
@@ -533,6 +586,10 @@ export class StorageService {
|
|||||||
await deleteStoredAttachment(this.db, id);
|
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> {
|
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||||
}
|
}
|
||||||
@@ -553,6 +610,10 @@ export class StorageService {
|
|||||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
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> {
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
@@ -573,9 +634,13 @@ export class StorageService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
expiresAtMs?: number,
|
expiresAtMs?: number,
|
||||||
deviceIdentifier?: string | null,
|
deviceIdentifier?: string | null,
|
||||||
deviceSessionStamp?: string | null
|
deviceSessionStamp?: string | null,
|
||||||
|
securityStamp?: string | null,
|
||||||
|
clientType?: string | null,
|
||||||
|
absoluteExpiresAtMs?: number | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
const now = Date.now();
|
||||||
|
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
|
||||||
await saveStoredRefreshToken(
|
await saveStoredRefreshToken(
|
||||||
this.db,
|
this.db,
|
||||||
this.refreshTokenKey.bind(this),
|
this.refreshTokenKey.bind(this),
|
||||||
@@ -584,7 +649,10 @@ export class StorageService {
|
|||||||
userId,
|
userId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
deviceIdentifier,
|
deviceIdentifier,
|
||||||
deviceSessionStamp
|
deviceSessionStamp,
|
||||||
|
securityStamp,
|
||||||
|
clientType,
|
||||||
|
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +681,10 @@ export class StorageService {
|
|||||||
return findStoredSend(this.db, id);
|
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> {
|
async saveSend(send: Send): Promise<void> {
|
||||||
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
||||||
}
|
}
|
||||||
@@ -654,11 +726,16 @@ export class StorageService {
|
|||||||
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
|
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for rotated refresh token to reduce
|
async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
|
||||||
// multi-context refresh races (e.g. browser extension popup/background).
|
return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
|
||||||
// Expiry is only tightened, never extended.
|
}
|
||||||
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
|
|
||||||
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs);
|
async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
|
||||||
|
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
|
||||||
|
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
||||||
@@ -699,6 +776,10 @@ export class StorageService {
|
|||||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
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(
|
async updateDeviceKeys(
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceIdentifier: string,
|
deviceIdentifier: string,
|
||||||
@@ -762,6 +843,10 @@ export class StorageService {
|
|||||||
return findStoredAuthRequestById(this.db, id);
|
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[]> {
|
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||||
return listStoredAuthRequestsByUserId(this.db, userId);
|
return listStoredAuthRequestsByUserId(this.db, userId);
|
||||||
}
|
}
|
||||||
@@ -823,6 +908,24 @@ export class StorageService {
|
|||||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
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 ---
|
// --- Revision dates ---
|
||||||
|
|
||||||
async getRevisionDate(userId: string): Promise<string> {
|
async getRevisionDate(userId: string): Promise<string> {
|
||||||
|
|||||||
+84
-5
@@ -14,15 +14,17 @@ export interface Env {
|
|||||||
WEBAUTHN_RP_ID?: string;
|
WEBAUTHN_RP_ID?: string;
|
||||||
WEBAUTHN_RP_NAME?: string;
|
WEBAUTHN_RP_NAME?: string;
|
||||||
WEBAUTHN_ALLOWED_ORIGINS?: 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 UserRole = 'admin' | 'user';
|
||||||
export type UserStatus = 'active' | 'banned';
|
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
|
// Attachment model
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +55,12 @@ export interface User {
|
|||||||
verifyDevices?: boolean;
|
verifyDevices?: boolean;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpRecoveryCode: 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;
|
apiKey: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -116,6 +124,10 @@ export enum CipherType {
|
|||||||
SecureNote = 2,
|
SecureNote = 2,
|
||||||
Card = 3,
|
Card = 3,
|
||||||
Identity = 4,
|
Identity = 4,
|
||||||
|
SSHKey = 5,
|
||||||
|
BankAccount = 6,
|
||||||
|
DriversLicense = 7,
|
||||||
|
Passport = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherLoginUri {
|
export interface CipherLoginUri {
|
||||||
@@ -150,6 +162,52 @@ export interface CipherSshKey {
|
|||||||
keyFingerprint: string;
|
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 {
|
export interface CipherIdentity {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
@@ -200,6 +258,9 @@ export interface Cipher {
|
|||||||
identity: CipherIdentity | null;
|
identity: CipherIdentity | null;
|
||||||
secureNote: CipherSecureNote | null;
|
secureNote: CipherSecureNote | null;
|
||||||
sshKey: CipherSshKey | null;
|
sshKey: CipherSshKey | null;
|
||||||
|
bankAccount?: CipherBankAccount | null;
|
||||||
|
driversLicense?: CipherDriversLicense | null;
|
||||||
|
passport?: CipherPassport | null;
|
||||||
fields: CipherField[] | null;
|
fields: CipherField[] | null;
|
||||||
passwordHistory: PasswordHistory[] | null;
|
passwordHistory: PasswordHistory[] | null;
|
||||||
reprompt: number;
|
reprompt: number;
|
||||||
@@ -244,6 +305,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
|||||||
export interface AccountPasskeyCredential {
|
export interface AccountPasskeyCredential {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
purpose: 'login' | 'twoFactor';
|
||||||
name: string;
|
name: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
@@ -259,7 +321,12 @@ export interface AccountPasskeyCredential {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
export type AccountPasskeyChallengeScope =
|
||||||
|
| 'Authentication'
|
||||||
|
| 'CreateCredential'
|
||||||
|
| 'UpdateKeySet'
|
||||||
|
| 'TwoFactorAuthentication'
|
||||||
|
| 'TwoFactorCreate';
|
||||||
|
|
||||||
export interface AccountPasskeyChallenge {
|
export interface AccountPasskeyChallenge {
|
||||||
challengeHash: string;
|
challengeHash: string;
|
||||||
@@ -307,6 +374,7 @@ export interface DeviceResponse {
|
|||||||
type: number;
|
type: number;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
|
lastActivityDate?: string | null;
|
||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
hasStoredDevice?: boolean;
|
hasStoredDevice?: boolean;
|
||||||
isTrusted: boolean;
|
isTrusted: boolean;
|
||||||
@@ -334,6 +402,11 @@ export interface RefreshTokenRecord {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
deviceSessionStamp: string | null;
|
deviceSessionStamp: string | null;
|
||||||
|
securityStamp: string | null;
|
||||||
|
createdAt: number | null;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
absoluteExpiresAt: number | null;
|
||||||
|
clientType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrustedDeviceTokenSummary {
|
export interface TrustedDeviceTokenSummary {
|
||||||
@@ -466,6 +539,8 @@ export interface TokenResponse {
|
|||||||
ResetMasterPassword: boolean;
|
ResetMasterPassword: boolean;
|
||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
|
UserVerificationToken?: string;
|
||||||
|
userVerificationToken?: string;
|
||||||
MasterPasswordPolicy?: {
|
MasterPasswordPolicy?: {
|
||||||
minComplexity: number;
|
minComplexity: number;
|
||||||
minLength: number;
|
minLength: number;
|
||||||
@@ -499,6 +574,7 @@ export interface ProfileResponse {
|
|||||||
masterPasswordHint: string | null;
|
masterPasswordHint: string | null;
|
||||||
culture: string;
|
culture: string;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
|
yubikeyEnabled?: boolean;
|
||||||
key: string;
|
key: string;
|
||||||
privateKey: string | null;
|
privateKey: string | null;
|
||||||
accountKeys: any | null;
|
accountKeys: any | null;
|
||||||
@@ -529,6 +605,9 @@ export interface CipherResponse {
|
|||||||
identity: CipherIdentity | null;
|
identity: CipherIdentity | null;
|
||||||
secureNote: CipherSecureNote | null;
|
secureNote: CipherSecureNote | null;
|
||||||
sshKey: CipherSshKey | null;
|
sshKey: CipherSshKey | null;
|
||||||
|
bankAccount: CipherBankAccount | null;
|
||||||
|
driversLicense: CipherDriversLicense | null;
|
||||||
|
passport: CipherPassport | null;
|
||||||
fields: CipherField[] | null;
|
fields: CipherField[] | null;
|
||||||
passwordHistory: PasswordHistory[] | null;
|
passwordHistory: PasswordHistory[] | null;
|
||||||
reprompt: number;
|
reprompt: number;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
WebAuthnPrfDecryptionOption,
|
WebAuthnPrfDecryptionOption,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
|
||||||
|
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||||
@@ -32,6 +33,44 @@ function textBytes(value: string): Uint8Array {
|
|||||||
return new TextEncoder().encode(value);
|
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> {
|
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||||
}
|
}
|
||||||
@@ -59,7 +98,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||||
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
|
||||||
|
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
|
||||||
|
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccountPasskeyToken(
|
export async function createAccountPasskeyToken(
|
||||||
@@ -119,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
|
|||||||
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||||
const rpId = configuredRpId || url.hostname;
|
const rpId = configuredRpId || url.hostname;
|
||||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
|
||||||
.split(',')
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
||||||
const requestOrigin = request.headers.get('Origin');
|
|
||||||
if (
|
|
||||||
requestOrigin
|
|
||||||
&& (
|
|
||||||
requestOrigin.startsWith('chrome-extension://')
|
|
||||||
|| requestOrigin.startsWith('moz-extension://')
|
|
||||||
|| requestOrigin.startsWith('safari-web-extension://')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
origins.add(requestOrigin);
|
|
||||||
}
|
|
||||||
return { rpId, rpName, origins: Array.from(origins) };
|
return { rpId, rpName, origins: Array.from(origins) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
||||||
return textBytes(userId);
|
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
||||||
if (!userHandle) return null;
|
if (!userHandle) return null;
|
||||||
try {
|
try {
|
||||||
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
|
const bytes = base64UrlToBytes(userHandle);
|
||||||
|
const officialGuid = dotNetGuidBytesToUuid(bytes);
|
||||||
|
if (officialGuid) return officialGuid;
|
||||||
|
const decoded = new TextDecoder().decode(bytes);
|
||||||
return decoded.trim() || null;
|
return decoded.trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -207,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
|
|||||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||||
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
||||||
return {
|
return {
|
||||||
id: String(input.id),
|
id: normalizeWebAuthnBase64(input.id),
|
||||||
rawId: String(input.rawId),
|
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
authenticatorAttachment: input.authenticatorAttachment,
|
authenticatorAttachment: input.authenticatorAttachment,
|
||||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||||
response: {
|
response: {
|
||||||
attestationObject: String(response.attestationObject),
|
attestationObject: normalizeWebAuthnBase64(response.attestationObject),
|
||||||
clientDataJSON: String(clientDataJSON),
|
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||||
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
|
authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
|
||||||
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
|
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
|
||||||
publicKey: response.publicKey ? String(response.publicKey) : undefined,
|
publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
|
||||||
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -230,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
|
|||||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||||
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
||||||
return {
|
return {
|
||||||
id: String(input.id),
|
id: normalizeWebAuthnBase64(input.id),
|
||||||
rawId: String(input.rawId),
|
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
authenticatorAttachment: input.authenticatorAttachment,
|
authenticatorAttachment: input.authenticatorAttachment,
|
||||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||||
response: {
|
response: {
|
||||||
authenticatorData: String(response.authenticatorData),
|
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
|
||||||
clientDataJSON: String(clientDataJSON),
|
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||||
signature: String(response.signature),
|
signature: normalizeWebAuthnBase64(response.signature),
|
||||||
userHandle: response.userHandle ? String(response.userHandle) : undefined,
|
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const API_KEY_HASH_PREFIX = 'sha256:';
|
||||||
|
|
||||||
|
export function constantTimeEquals(a: string, b: string): boolean {
|
||||||
|
const encA = new TextEncoder().encode(a);
|
||||||
|
const encB = new TextEncoder().encode(b);
|
||||||
|
if (encA.length !== encB.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < encA.length; i++) {
|
||||||
|
diff |= encA[i] ^ encB[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(bytes: ArrayBuffer): string {
|
||||||
|
return [...new Uint8Array(bytes)]
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
|
||||||
|
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashApiKey(apiKey: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
|
||||||
|
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
|
||||||
|
const stored = String(storedApiKey || '').trim();
|
||||||
|
if (!isStoredApiKeyHash(stored)) return false;
|
||||||
|
|
||||||
|
const hashed = await hashApiKey(apiKey);
|
||||||
|
return constantTimeEquals(hashed, stored);
|
||||||
|
}
|
||||||
@@ -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 { LIMITS } from '../config/limits';
|
||||||
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { errorResponse } from './response';
|
import { errorResponse } from './response';
|
||||||
|
|
||||||
export interface DirectUploadPayload {
|
export interface DirectUploadPayload {
|
||||||
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
|
|||||||
fileNameMismatchMessage?: string;
|
fileNameMismatchMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
|
||||||
|
|
||||||
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||||
const version = '2023-11-03';
|
const version = '2023-11-03';
|
||||||
const expiresAt = '2099-12-31T23:59:59Z';
|
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 {
|
export function getSafeJwtSecret(env: Env): string | null {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
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 null;
|
||||||
}
|
}
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
|
||||||
|
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
function parseContentLength(request: Request): number | null {
|
function parseContentLength(request: Request): number | null {
|
||||||
const raw = request.headers.get('content-length');
|
const raw = request.headers.get('content-length');
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
|
|||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|
||||||
if (contentType.includes('multipart/form-data')) {
|
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 formData = await request.formData();
|
||||||
const file = formData.get('data') as File | null;
|
const file = formData.get('data') as File | null;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
|
||||||
|
// These are the stable store IDs for the official Chromium-based extensions.
|
||||||
|
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||||
|
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
|
||||||
|
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
|
||||||
|
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function normalizeOrigin(value: unknown): string | null {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!url.protocol || !url.host) return null;
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && (
|
||||||
|
normalized.startsWith('chrome-extension://')
|
||||||
|
|| normalized.startsWith('moz-extension://')
|
||||||
|
|| normalized.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredWebAuthnAllowedOrigins(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
|
||||||
|
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||||
|
const origin = normalizeOrigin(item);
|
||||||
|
if (origin) seen.add(origin);
|
||||||
|
}
|
||||||
|
return Array.from(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfiguredWebAuthnAllowedOrigin(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||||
|
origin: unknown
|
||||||
|
): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Env, ProfileResponse, User } from '../types';
|
import type { Env, ProfileResponse, User } from '../types';
|
||||||
import { buildAccountKeys } from './user-decryption';
|
import { buildAccountKeys } from './user-decryption';
|
||||||
|
import { isYubiKeyEnabled } from './yubico-otp';
|
||||||
|
|
||||||
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
|||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: user.masterPasswordHint,
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
|
||||||
|
yubikeyEnabled: isYubiKeyEnabled(user),
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys,
|
accountKeys,
|
||||||
@@ -28,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
verifyDevices: user.verifyDevices !== false,
|
// New-device verification is not supported without an email delivery channel.
|
||||||
|
// Always report disabled so clients do not present a false security posture.
|
||||||
|
verifyDevices: false,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
|
|||||||
+35
-20
@@ -1,4 +1,10 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
isBrowserExtensionOrigin,
|
||||||
|
isConfiguredWebAuthnAllowedOrigin,
|
||||||
|
normalizeOrigin,
|
||||||
|
} from './origins';
|
||||||
|
|
||||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const DEFAULT_CORS_HEADERS = [
|
const DEFAULT_CORS_HEADERS = [
|
||||||
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-NodeWarden-Web-Session',
|
'X-NodeWarden-Web-Session',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isExtensionOrigin(origin: string): boolean {
|
|
||||||
return (
|
|
||||||
origin.startsWith('chrome-extension://')
|
|
||||||
|| origin.startsWith('moz-extension://')
|
|
||||||
|| origin.startsWith('safari-web-extension://')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWildcardCorsPath(path: string): boolean {
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith('/icons/')
|
path.startsWith('/icons/')
|
||||||
|
|| path.startsWith('/fill-assist/')
|
||||||
|
|| path === '/v1/assetlinks:check'
|
||||||
|
|| path === '/api/v1/assetlinks:check'
|
||||||
|| path === '/config'
|
|| path === '/config'
|
||||||
|| path === '/api/config'
|
|| path === '/api/config'
|
||||||
|| path === '/api/version'
|
|| 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 url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const originHeader = request.headers.get('Origin');
|
||||||
if (!origin) {
|
if (!originHeader) {
|
||||||
return isWildcardCorsPath(url.pathname)
|
return isWildcardCorsPath(url.pathname)
|
||||||
? { allowOrigin: '*', allowCredentials: false }
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
: { allowOrigin: null, allowCredentials: false };
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isExtensionOrigin(origin)) {
|
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
return { allowOrigin: null, allowCredentials: false };
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
|
||||||
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
const corsPolicy = getCorsPolicy(request);
|
const corsPolicy = getCorsPolicy(request, env);
|
||||||
if (corsPolicy.allowOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
if (corsPolicy.allowCredentials) {
|
if (corsPolicy.allowCredentials) {
|
||||||
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
|
|
||||||
export function applyCors(
|
export function applyCors(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response,
|
||||||
|
env: Env
|
||||||
): Response {
|
): Response {
|
||||||
// WebSocket upgrade responses must be returned untouched.
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
@@ -92,7 +95,7 @@ export function applyCors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
const corsHeaders = buildCorsHeaders(request);
|
const corsHeaders = buildCorsHeaders(request, env);
|
||||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
headers.set(k, v);
|
headers.set(k, v);
|
||||||
}
|
}
|
||||||
@@ -100,7 +103,9 @@ export function applyCors(
|
|||||||
headers.set('X-Frame-Options', 'DENY');
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
if (!headers.has('Content-Security-Policy')) {
|
||||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||||
|
}
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
@@ -134,8 +139,17 @@ 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)
|
// Identity endpoint error response (for /identity/connect/token)
|
||||||
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
|
export function identityErrorResponse(
|
||||||
|
message: string,
|
||||||
|
error: string = 'invalid_grant',
|
||||||
|
status: number = 400,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
): Response {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
error: error,
|
error: error,
|
||||||
@@ -145,15 +159,16 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
Object: 'error',
|
Object: 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status
|
status,
|
||||||
|
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(request: Request): Response {
|
export function handleCors(request: Request, env: Env): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: buildCorsHeaders(request),
|
headers: buildCorsHeaders(request, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-7
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
|
|||||||
return token.replace(/\s+/g, '');
|
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);
|
const token = normalizeToken(tokenRaw);
|
||||||
if (!/^\d{6}$/.test(token)) return false;
|
if (!/^\d{6}$/.test(token)) return null;
|
||||||
|
|
||||||
const secret = base32Decode(secretRaw);
|
const secret = base32Decode(secretRaw);
|
||||||
if (!secret) return false;
|
if (!secret) return null;
|
||||||
|
|
||||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
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++) {
|
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.
|
// Constant-time comparison: always check all windows, never short-circuit.
|
||||||
const a = new TextEncoder().encode(expected);
|
const a = new TextEncoder().encode(expected);
|
||||||
const b = new TextEncoder().encode(token);
|
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++) {
|
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||||
diff |= a[i] ^ b[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 {
|
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data:;
|
||||||
connect-src 'self';
|
connect-src 'self' https://api.pwnedpasswords.com;
|
||||||
font-src 'self';
|
font-src 'self';
|
||||||
form-action 'self';
|
form-action 'self';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
|
|||||||
@@ -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>
|
||||||
+243
-46
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
|||||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
|
deriveLoginHash,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
clearProfileSnapshot,
|
clearProfileSnapshot,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
loadProfileSnapshot,
|
loadProfileSnapshot,
|
||||||
saveProfileSnapshot,
|
saveProfileSnapshot,
|
||||||
revokeCurrentSession,
|
revokeCurrentSession,
|
||||||
getTotpStatus,
|
getTwoFactorProviderStatus,
|
||||||
getVaultRevisionDate,
|
getVaultRevisionDate,
|
||||||
saveSession,
|
saveSession,
|
||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
@@ -57,6 +58,7 @@ import {
|
|||||||
type PendingPasskeyPassword,
|
type PendingPasskeyPassword,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
} from '@/lib/app-auth';
|
} from '@/lib/app-auth';
|
||||||
|
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
|
||||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||||
import useAdminActions from '@/hooks/useAdminActions';
|
import useAdminActions from '@/hooks/useAdminActions';
|
||||||
import useBackupActions from '@/hooks/useBackupActions';
|
import useBackupActions from '@/hooks/useBackupActions';
|
||||||
@@ -66,6 +68,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||||
|
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
|
||||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +112,8 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/',
|
'/',
|
||||||
'/vault',
|
'/vault',
|
||||||
'/vault/totp',
|
'/vault/totp',
|
||||||
|
'/security/password-health',
|
||||||
|
'/generator',
|
||||||
'/sends',
|
'/sends',
|
||||||
'/admin',
|
'/admin',
|
||||||
'/logs',
|
'/logs',
|
||||||
@@ -151,6 +156,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
|||||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||||
|
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||||
|
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||||
@@ -224,6 +231,7 @@ export default function App() {
|
|||||||
hint: null,
|
hint: null,
|
||||||
});
|
});
|
||||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||||
|
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
|
||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||||
@@ -237,6 +245,7 @@ export default function App() {
|
|||||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||||
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
||||||
|
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
|
||||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
@@ -244,6 +253,8 @@ export default function App() {
|
|||||||
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
|
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
|
||||||
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
|
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
|
||||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
|
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
|
||||||
|
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
|
||||||
|
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
const [mobileLayout, setMobileLayout] = useState(false);
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
@@ -260,10 +271,16 @@ export default function App() {
|
|||||||
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
||||||
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
|
const lockedSessionRetryAttemptRef = useRef(0);
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
|
const loginScopedBackupRepairAuthRef = useRef<{
|
||||||
|
accessToken: string;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
userVerificationToken?: string | null;
|
||||||
|
} | null>(null);
|
||||||
const uriChecksumRepairAttemptRef = useRef<string>('');
|
const uriChecksumRepairAttemptRef = useRef<string>('');
|
||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
@@ -285,15 +302,16 @@ export default function App() {
|
|||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncInviteFromUrl = () => {
|
const syncUrlState = () => {
|
||||||
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
||||||
|
setHashPathRaw(window.location.hash || '');
|
||||||
};
|
};
|
||||||
syncInviteFromUrl();
|
syncUrlState();
|
||||||
window.addEventListener('hashchange', syncInviteFromUrl);
|
window.addEventListener('hashchange', syncUrlState);
|
||||||
window.addEventListener('popstate', syncInviteFromUrl);
|
window.addEventListener('popstate', syncUrlState);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', syncInviteFromUrl);
|
window.removeEventListener('hashchange', syncUrlState);
|
||||||
window.removeEventListener('popstate', syncInviteFromUrl);
|
window.removeEventListener('popstate', syncUrlState);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -373,6 +391,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [phase, profile, session]);
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'app') clearPasswordSecurityCache();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||||
@@ -484,13 +506,15 @@ export default function App() {
|
|||||||
if (phase !== 'locked' || !session) return;
|
if (phase !== 'locked' || !session) return;
|
||||||
if (IS_DEMO_MODE) return;
|
if (IS_DEMO_MODE) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let retryTimerId: number | null = null;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const result = await hydrateLockedSession(session, profile);
|
const result = await hydrateLockedSession(session, profile);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!result.session) {
|
if (result.kind === 'expired') {
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
|
setLockedSessionRefreshError('');
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
if (location !== '/login') navigate('/login');
|
if (location !== '/login') navigate('/login');
|
||||||
return;
|
return;
|
||||||
@@ -499,16 +523,57 @@ export default function App() {
|
|||||||
if (result.profile) {
|
if (result.profile) {
|
||||||
setProfile(stripProfileSecrets(result.profile));
|
setProfile(stripProfileSecrets(result.profile));
|
||||||
}
|
}
|
||||||
|
if (result.kind === 'transient') {
|
||||||
|
setUnlockPreparing(false);
|
||||||
|
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
|
||||||
|
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
|
||||||
|
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
|
||||||
|
lockedSessionRetryAttemptRef.current += 1;
|
||||||
|
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
|
||||||
|
retryTimerId = window.setTimeout(() => {
|
||||||
|
setLockedSessionRetryKey((value) => value + 1);
|
||||||
|
}, retryAfterMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lockedSessionRetryAttemptRef.current = 0;
|
||||||
|
setLockedSessionRefreshError('');
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
|
||||||
};
|
};
|
||||||
}, [phase, session?.email, location, navigate]);
|
}, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lockedSessionRefreshError || phase !== 'locked') return;
|
||||||
|
const retryNow = () => {
|
||||||
|
lockedSessionRetryAttemptRef.current = 0;
|
||||||
|
setLockedSessionRetryKey((value) => value + 1);
|
||||||
|
};
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') retryNow();
|
||||||
|
};
|
||||||
|
window.addEventListener('online', retryNow);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', retryNow);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
}, [lockedSessionRefreshError, phase]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
|
loginScopedBackupRepairAuthRef.current =
|
||||||
|
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
|
||||||
|
? {
|
||||||
|
accessToken: login.session.accessToken,
|
||||||
|
masterPasswordHash: login.freshMasterPasswordHash || null,
|
||||||
|
userVerificationToken: login.freshUserVerificationToken || null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
|
setLockedSessionRefreshError('');
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingTotpMode(null);
|
setPendingTotpMode(null);
|
||||||
setPendingPasskeyPassword(null);
|
setPendingPasskeyPassword(null);
|
||||||
@@ -639,19 +704,38 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSelectTotpProvider(providerType: number) {
|
||||||
|
if (totpSubmitting) return;
|
||||||
|
setPendingTotp((current) => {
|
||||||
|
if (!current || current.providerType === providerType) return current;
|
||||||
|
const canUseProvider = current.availableProviders.includes(providerType);
|
||||||
|
if (!canUseProvider) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
providerType,
|
||||||
|
providerData: current.providerDataByType[providerType],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setTotpCode('');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTotpVerify() {
|
async function handleTotpVerify() {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
if (!totpCode.trim()) {
|
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||||
pushToast('error', t('txt_please_input_totp_code'));
|
if (!isPasskeyTwoFactor && !totpCode.trim()) {
|
||||||
|
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTotpSubmitting(true);
|
setTotpSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const token = isPasskeyTwoFactor
|
||||||
|
? await assertTwoFactorPasskey(pendingTotp.providerData)
|
||||||
|
: totpCode;
|
||||||
|
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
|
||||||
await finalizeLogin(login);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setTotpSubmitting(false);
|
setTotpSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -832,11 +916,13 @@ export default function App() {
|
|||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingTotpMode(null);
|
setPendingTotpMode(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
|
setLockedSessionRefreshError('');
|
||||||
setPhase('locked');
|
setPhase('locked');
|
||||||
navigate('/lock');
|
navigate('/lock');
|
||||||
}
|
}
|
||||||
@@ -853,6 +939,7 @@ export default function App() {
|
|||||||
setSession(null);
|
setSession(null);
|
||||||
clearProfileSnapshot();
|
clearProfileSnapshot();
|
||||||
clearOfflineUnlockRecord();
|
clearOfflineUnlockRecord();
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -936,11 +1023,14 @@ export default function App() {
|
|||||||
confirm={null}
|
confirm={null}
|
||||||
onCancelConfirm={() => {}}
|
onCancelConfirm={() => {}}
|
||||||
pendingTotpOpen={false}
|
pendingTotpOpen={false}
|
||||||
|
pendingTotpProviderType={0}
|
||||||
|
pendingTotpAvailableProviders={[]}
|
||||||
totpCode=""
|
totpCode=""
|
||||||
rememberDevice={false}
|
rememberDevice={false}
|
||||||
onTotpCodeChange={() => {}}
|
onTotpCodeChange={() => {}}
|
||||||
onRememberDeviceChange={() => {}}
|
onRememberDeviceChange={() => {}}
|
||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
|
onSelectTotpProvider={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
totpSubmitting={false}
|
totpSubmitting={false}
|
||||||
@@ -1066,9 +1156,9 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const totpStatusQuery = useQuery({
|
const twoFactorStatusQuery = useQuery({
|
||||||
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
queryKey: ['two-factor-status', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getTotpStatus(authedFetch),
|
queryFn: () => getTwoFactorProviderStatus(authedFetch),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
@@ -1085,18 +1175,38 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
|
||||||
|
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
|
||||||
|
if (!email) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalizedPassword = String(masterPassword || '');
|
||||||
|
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
|
||||||
|
return derived.hash;
|
||||||
|
}
|
||||||
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||||
const pendingAuthRequestsQuery = useQuery({
|
const pendingAuthRequestsQuery = useQuery({
|
||||||
queryKey: pendingAuthRequestsQueryKey,
|
queryKey: pendingAuthRequestsQueryKey,
|
||||||
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: 15_000,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
});
|
});
|
||||||
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
||||||
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
||||||
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
|
const selectedPendingAuthRequest = authRequestDialogSelectedId
|
||||||
|
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
|
||||||
|
: null;
|
||||||
|
const authRequestDialogRequest = selectedPendingAuthRequest || (
|
||||||
|
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
|
||||||
|
? latestPendingAuthRequest
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
const authRequestDialogOpen = !!authRequestDialogRequest;
|
||||||
|
|
||||||
|
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
|
setAuthRequestDialogSelectedId(authRequest.id);
|
||||||
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
@@ -1105,11 +1215,11 @@ export default function App() {
|
|||||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
key,
|
key,
|
||||||
masterPasswordHash: null,
|
|
||||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
requestApproved: true,
|
requestApproved: true,
|
||||||
});
|
});
|
||||||
setAuthRequestDialogDismissedId(null);
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
setAuthRequestDialogSelectedId(null);
|
||||||
pushToast('success', t('txt_auth_request_approved'));
|
pushToast('success', t('txt_auth_request_approved'));
|
||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1125,6 +1235,7 @@ export default function App() {
|
|||||||
requestApproved: false,
|
requestApproved: false,
|
||||||
});
|
});
|
||||||
setAuthRequestDialogDismissedId(null);
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
setAuthRequestDialogSelectedId(null);
|
||||||
pushToast('success', t('txt_auth_request_denied'));
|
pushToast('success', t('txt_auth_request_denied'));
|
||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1189,13 +1300,25 @@ export default function App() {
|
|||||||
if (!isAdminProfile(profile)) return;
|
if (!isAdminProfile(profile)) return;
|
||||||
if (repairAttemptRef.current === session.accessToken) return;
|
if (repairAttemptRef.current === session.accessToken) return;
|
||||||
|
|
||||||
|
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
|
||||||
|
? loginScopedBackupRepairAuthRef.current
|
||||||
|
: null;
|
||||||
repairAttemptRef.current = session.accessToken;
|
repairAttemptRef.current = session.accessToken;
|
||||||
void silentlyRepairBackupSettingsIfNeeded(session, profile);
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
|
||||||
|
} finally {
|
||||||
|
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
|
||||||
|
loginScopedBackupRepairAuthRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
|
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.accessToken) return;
|
if (session?.accessToken) return;
|
||||||
repairAttemptRef.current = '';
|
repairAttemptRef.current = '';
|
||||||
|
loginScopedBackupRepairAuthRef.current = null;
|
||||||
uriChecksumRepairAttemptRef.current = '';
|
uriChecksumRepairAttemptRef.current = '';
|
||||||
}, [session?.accessToken]);
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
@@ -1767,11 +1890,13 @@ export default function App() {
|
|||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
onProfileUpdated: setProfile,
|
onProfileUpdated: setProfile,
|
||||||
onSetConfirm: setConfirm,
|
onSetConfirm: setConfirm,
|
||||||
refetchTotpStatus: totpStatusQuery.refetch,
|
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
|
||||||
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
||||||
});
|
});
|
||||||
const adminActions = useAdminActions({
|
const adminActions = useAdminActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
|
email: String(profile?.email || session?.email || ''),
|
||||||
|
defaultKdfIterations,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
onSetConfirm: setConfirm,
|
onSetConfirm: setConfirm,
|
||||||
refetchUsers: usersQuery.refetch,
|
refetchUsers: usersQuery.refetch,
|
||||||
@@ -1788,7 +1913,6 @@ export default function App() {
|
|||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||||
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
@@ -1827,13 +1951,17 @@ export default function App() {
|
|||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
location === '/sends'
|
location === '/sends'
|
||||||
? '/sends'
|
? '/sends'
|
||||||
|
: location === '/generator'
|
||||||
|
? '/generator'
|
||||||
: location === '/vault/totp'
|
: location === '/vault/totp'
|
||||||
? '/vault/totp'
|
? '/vault/totp'
|
||||||
: location === '/vault'
|
: location === '/vault'
|
||||||
? '/vault'
|
? '/vault'
|
||||||
: '/settings';
|
: '/settings';
|
||||||
const currentPageTitle = (() => {
|
const currentPageTitle = (() => {
|
||||||
|
if (location === '/security/password-health') return t('txt_password_security');
|
||||||
if (location === '/vault/totp') return t('txt_verification_code');
|
if (location === '/vault/totp') return t('txt_verification_code');
|
||||||
|
if (location === '/generator') return t('txt_password_generator');
|
||||||
if (location === '/sends') return t('nav_sends');
|
if (location === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/logs') return t('nav_log_center');
|
if (location === '/logs') return t('nav_log_center');
|
||||||
@@ -1890,6 +2018,7 @@ export default function App() {
|
|||||||
session,
|
session,
|
||||||
mobileLayout,
|
mobileLayout,
|
||||||
mobileSidebarToggleKey,
|
mobileSidebarToggleKey,
|
||||||
|
themePreference,
|
||||||
importRoute: IMPORT_ROUTE,
|
importRoute: IMPORT_ROUTE,
|
||||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||||
@@ -1904,10 +2033,13 @@ export default function App() {
|
|||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
||||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||||
|
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||||
|
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
|
||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
sessionTimeoutAction,
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
|
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||||
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
||||||
@@ -1916,6 +2048,7 @@ export default function App() {
|
|||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
onLogout: handleLogout,
|
onLogout: handleLogout,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
|
onThemePreferenceChange: setThemePreference,
|
||||||
onImport: vaultSendActions.importVault,
|
onImport: vaultSendActions.importVault,
|
||||||
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
||||||
onExport: vaultSendActions.exportVault,
|
onExport: vaultSendActions.exportVault,
|
||||||
@@ -1950,11 +2083,20 @@ export default function App() {
|
|||||||
sendUploadPercent: vaultSendActions.sendUploadPercent,
|
sendUploadPercent: vaultSendActions.sendUploadPercent,
|
||||||
onChangePassword: accountSecurityActions.changePassword,
|
onChangePassword: accountSecurityActions.changePassword,
|
||||||
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
||||||
onEnableTotp: async (secret: string, token: string) => {
|
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
|
||||||
await accountSecurityActions.enableTotp(secret, token);
|
await accountSecurityActions.enableTotp(secret, token, masterPassword);
|
||||||
await totpStatusQuery.refetch();
|
await twoFactorStatusQuery.refetch();
|
||||||
},
|
},
|
||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
|
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
|
||||||
|
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
|
||||||
|
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||||
|
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||||
|
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||||
|
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
|
||||||
|
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
|
||||||
|
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
|
||||||
|
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
onGetApiKey: accountSecurityActions.getApiKey,
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
@@ -1962,12 +2104,16 @@ export default function App() {
|
|||||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||||
|
onRefreshTwoFactorStatus: async () => {
|
||||||
|
await twoFactorStatusQuery.refetch();
|
||||||
|
},
|
||||||
pendingAuthRequests,
|
pendingAuthRequests,
|
||||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
|
||||||
|
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
|
||||||
onRefreshPendingAuthRequests: async () => {
|
onRefreshPendingAuthRequests: async () => {
|
||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
},
|
},
|
||||||
onApproveAuthRequest: approveAuthRequest,
|
onApproveAuthRequest: beginApproveAuthRequest,
|
||||||
onDenyAuthRequest: denyAuthRequest,
|
onDenyAuthRequest: denyAuthRequest,
|
||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
@@ -1980,34 +2126,70 @@ export default function App() {
|
|||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
|
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||||
|
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
|
||||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||||
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
|
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
|
||||||
onRefreshAdmin: adminActions.refreshAdmin,
|
onRefreshAdmin: adminActions.refreshAdmin,
|
||||||
onCreateInvite: adminActions.createInvite,
|
onCreateInvite: adminActions.createInvite,
|
||||||
|
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
|
||||||
onDeleteAllInvites: adminActions.deleteAllInvites,
|
onDeleteAllInvites: adminActions.deleteAllInvites,
|
||||||
onToggleUserStatus: adminActions.toggleUserStatus,
|
onToggleUserStatus: adminActions.toggleUserStatus,
|
||||||
onDeleteUser: adminActions.deleteUser,
|
onDeleteUser: adminActions.deleteUser,
|
||||||
onRevokeInvite: adminActions.revokeInvite,
|
onDeleteInvite: adminActions.deleteInvite,
|
||||||
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
|
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
|
||||||
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
||||||
onClearAuditLogs: () => clearAuditLogs(authedFetch),
|
onClearAuditLogs: () => clearAuditLogs(authedFetch),
|
||||||
onExportBackup: backupActions.exportBackup,
|
onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
|
||||||
onImportBackup: backupActions.importBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
return backupActions.exportBackup(hash, includeAttachments);
|
||||||
|
},
|
||||||
|
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.importBackup(hash, file, replaceExisting);
|
||||||
|
},
|
||||||
|
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
|
||||||
|
},
|
||||||
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
||||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
queryFn: () => backupActions.loadSettings(),
|
queryFn: () => backupActions.loadSettings(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
}),
|
}),
|
||||||
onSaveBackupSettings: backupActions.saveSettings,
|
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
||||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
const saved = await backupActions.saveSettings(hash, settings);
|
||||||
|
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
|
||||||
|
return saved;
|
||||||
|
},
|
||||||
|
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
const result = await backupActions.runRemoteBackup(hash, destinationId);
|
||||||
|
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
||||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
},
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.inspectRemoteBackup(hash, destinationId, path);
|
||||||
|
},
|
||||||
|
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.deleteRemoteBackup(hash, destinationId, path);
|
||||||
|
},
|
||||||
|
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
|
||||||
|
},
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||||
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const effectiveMainRoutesProps = IS_DEMO_MODE
|
const effectiveMainRoutesProps = IS_DEMO_MODE
|
||||||
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
|
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
|
||||||
@@ -2078,6 +2260,7 @@ export default function App() {
|
|||||||
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
|
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
|
||||||
unlockReady={!!session?.email}
|
unlockReady={!!session?.email}
|
||||||
unlockPreparing={unlockPreparing}
|
unlockPreparing={unlockPreparing}
|
||||||
|
sessionRefreshError={lockedSessionRefreshError}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
||||||
passkeyPassword={passkeyPassword}
|
passkeyPassword={passkeyPassword}
|
||||||
@@ -2118,6 +2301,11 @@ export default function App() {
|
|||||||
onLogout={logoutNow}
|
onLogout={logoutNow}
|
||||||
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
||||||
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
||||||
|
onRetrySessionRefresh={() => {
|
||||||
|
lockedSessionRetryAttemptRef.current = 0;
|
||||||
|
setLockedSessionRefreshError('');
|
||||||
|
setLockedSessionRetryKey((value) => value + 1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<AppGlobalOverlays
|
<AppGlobalOverlays
|
||||||
toasts={toasts}
|
toasts={toasts}
|
||||||
@@ -2125,11 +2313,14 @@ export default function App() {
|
|||||||
confirm={confirm}
|
confirm={confirm}
|
||||||
onCancelConfirm={() => setConfirm(null)}
|
onCancelConfirm={() => setConfirm(null)}
|
||||||
pendingTotpOpen={!!pendingTotp}
|
pendingTotpOpen={!!pendingTotp}
|
||||||
|
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||||
|
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
|
||||||
totpCode={totpCode}
|
totpCode={totpCode}
|
||||||
rememberDevice={rememberDevice}
|
rememberDevice={rememberDevice}
|
||||||
onTotpCodeChange={setTotpCode}
|
onTotpCodeChange={setTotpCode}
|
||||||
onRememberDeviceChange={setRememberDevice}
|
onRememberDeviceChange={setRememberDevice}
|
||||||
onConfirmTotp={() => void handleTotpVerify()}
|
onConfirmTotp={() => void handleTotpVerify()}
|
||||||
|
onSelectTotpProvider={handleSelectTotpProvider}
|
||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -2184,11 +2375,14 @@ export default function App() {
|
|||||||
confirm={confirm}
|
confirm={confirm}
|
||||||
onCancelConfirm={() => setConfirm(null)}
|
onCancelConfirm={() => setConfirm(null)}
|
||||||
pendingTotpOpen={false}
|
pendingTotpOpen={false}
|
||||||
|
pendingTotpProviderType={0}
|
||||||
|
pendingTotpAvailableProviders={[]}
|
||||||
totpCode=""
|
totpCode=""
|
||||||
rememberDevice={false}
|
rememberDevice={false}
|
||||||
onTotpCodeChange={() => {}}
|
onTotpCodeChange={() => {}}
|
||||||
onRememberDeviceChange={() => {}}
|
onRememberDeviceChange={() => {}}
|
||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
|
onSelectTotpProvider={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
totpSubmitting={false}
|
totpSubmitting={false}
|
||||||
@@ -2215,21 +2409,24 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<AuthRequestApprovalDialog
|
<AuthRequestApprovalDialog
|
||||||
open={authRequestDialogOpen}
|
open={authRequestDialogOpen}
|
||||||
authRequest={latestPendingAuthRequest}
|
authRequest={authRequestDialogRequest}
|
||||||
submitting={!!authRequestSubmittingId}
|
submitting={!!authRequestSubmittingId}
|
||||||
onApprove={() => {
|
onApprove={() => {
|
||||||
if (!latestPendingAuthRequest) return;
|
if (!authRequestDialogRequest) return;
|
||||||
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
|
void approveAuthRequest(authRequestDialogRequest).catch((error) => {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onDeny={() => {
|
onDeny={() => {
|
||||||
if (!latestPendingAuthRequest) return;
|
if (!authRequestDialogRequest) return;
|
||||||
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
|
void denyAuthRequest(authRequestDialogRequest).catch((error) => {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
|
onClose={() => {
|
||||||
|
setAuthRequestDialogSelectedId(null);
|
||||||
|
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ interface AdminPageProps {
|
|||||||
error: string;
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onDeleteInvalidInvites: () => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onDeleteInvite: (code: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage(props: AdminPageProps) {
|
export default function AdminPage(props: AdminPageProps) {
|
||||||
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<h3>{t('txt_invites')}</h3>
|
<h3>{t('txt_invites')}</h3>
|
||||||
<div className="actions admin-invites-head-actions">
|
<div className="actions admin-invites-head-actions">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
<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>
|
||||||
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
<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')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
</button>
|
</button>
|
||||||
{invite.status === 'active' && (
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||||
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
|
|||||||
mainRoutesProps: AppMainRoutesProps;
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
|
const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
|
||||||
|
|
||||||
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
|
const DEFAULT_EXPANDED_GROUPS = {
|
||||||
|
tools: true,
|
||||||
|
settings: true,
|
||||||
|
management: true,
|
||||||
|
};
|
||||||
|
|
||||||
function readNavLayoutMode(): NavLayoutMode {
|
type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
|
||||||
if (typeof window === 'undefined') return 'flat';
|
type ExpandedGroups = Record<NavGroup, boolean>;
|
||||||
|
|
||||||
|
function readExpandedGroups(): ExpandedGroups {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
|
||||||
try {
|
try {
|
||||||
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
|
const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
|
||||||
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
|
if (!saved) return DEFAULT_EXPANDED_GROUPS;
|
||||||
|
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
|
||||||
|
return {
|
||||||
|
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
|
||||||
|
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
|
||||||
|
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local preference read failures.
|
// Ignore local preference read failures.
|
||||||
}
|
}
|
||||||
return 'flat';
|
return DEFAULT_EXPANDED_GROUPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdminProfile(profile: Profile | null): boolean {
|
function isAdminProfile(profile: Profile | null): boolean {
|
||||||
@@ -55,57 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||||
const isLogRoute = props.location === '/logs';
|
const isLogRoute = props.location === '/logs';
|
||||||
const isAdmin = isAdminProfile(props.profile);
|
const isAdmin = isAdminProfile(props.profile);
|
||||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
|
||||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
|
||||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
|
||||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||||
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
|
const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
|
||||||
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
|
||||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
|
||||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState({
|
|
||||||
vault: true,
|
|
||||||
settings: false,
|
|
||||||
data: false,
|
|
||||||
management: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
function toggleGroup(group: NavGroup): void {
|
||||||
const onPointerDown = (event: Event) => {
|
setExpandedGroups((current) => {
|
||||||
if (!navLayoutPickerOpen) return;
|
const next = { ...current, [group]: !current[group] };
|
||||||
const target = event.target as Node | null;
|
|
||||||
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
|
|
||||||
setNavLayoutPickerOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('pointerdown', onPointerDown);
|
|
||||||
document.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('pointerdown', onPointerDown);
|
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
|
||||||
};
|
|
||||||
}, [navLayoutPickerOpen]);
|
|
||||||
|
|
||||||
function setNavMode(mode: NavLayoutMode): void {
|
|
||||||
setNavLayoutMode(mode);
|
|
||||||
setNavLayoutPickerOpen(false);
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
|
window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local preference write failures.
|
// Ignore local preference write failures.
|
||||||
}
|
}
|
||||||
}
|
return next;
|
||||||
|
});
|
||||||
function toggleGroup(group: keyof typeof expandedGroups): void {
|
|
||||||
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
|
|
||||||
if (navLayoutMode === 'grouped-expanded') return true;
|
|
||||||
return expandedGroups[group] || active;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
|
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
|
||||||
@@ -126,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNavGroup(
|
function renderNavGroup(
|
||||||
group: keyof typeof expandedGroups,
|
group: NavGroup,
|
||||||
title: string,
|
title: string,
|
||||||
icon: ComponentChildren,
|
icon: ComponentChildren,
|
||||||
active: boolean,
|
|
||||||
children: ComponentChildren
|
children: ComponentChildren
|
||||||
) {
|
) {
|
||||||
const open = groupOpen(group, active);
|
const open = expandedGroups[group];
|
||||||
return (
|
return (
|
||||||
<div className={`side-nav-group ${open ? 'open' : ''}`}>
|
<div className={`side-nav-group ${open ? 'open' : ''}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`side-group-trigger ${active ? 'active' : ''}`}
|
className="side-group-trigger"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => toggleGroup(group)}
|
onClick={() => toggleGroup(group)}
|
||||||
>
|
>
|
||||||
@@ -154,79 +128,40 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
|
|
||||||
{
|
|
||||||
mode: 'flat',
|
|
||||||
label: t('txt_nav_layout_flat'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'grouped-expanded',
|
|
||||||
label: t('txt_nav_layout_grouped_expanded'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: 'grouped-smart',
|
|
||||||
label: t('txt_nav_layout_grouped_smart'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
|
|
||||||
const flatNav = (
|
|
||||||
<>
|
|
||||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
|
||||||
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
|
||||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
|
||||||
{renderSideLink(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'))}
|
|
||||||
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
|
|
||||||
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
|
||||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
|
||||||
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
|
||||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedNav = (
|
const groupedNav = (
|
||||||
<>
|
<>
|
||||||
{renderNavGroup(
|
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||||
'vault',
|
|
||||||
t('nav_my_vault'),
|
|
||||||
<KeyRound size={16} />,
|
|
||||||
vaultActive,
|
|
||||||
<>
|
|
||||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
|
||||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
{renderNavGroup(
|
{renderNavGroup(
|
||||||
'settings',
|
'tools',
|
||||||
t('txt_settings'),
|
t('nav_group_tools'),
|
||||||
<SettingsIcon size={16} />,
|
<Sparkles size={16} />,
|
||||||
settingsActive,
|
|
||||||
<>
|
<>
|
||||||
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||||
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
{renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
|
||||||
</>
|
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
|
||||||
)}
|
|
||||||
{renderNavGroup(
|
|
||||||
'data',
|
|
||||||
t('nav_group_data_backup'),
|
|
||||||
<Cloud size={16} />,
|
|
||||||
dataActive,
|
|
||||||
<>
|
|
||||||
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
|
|
||||||
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
|
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{renderNavGroup(
|
{renderNavGroup(
|
||||||
'management',
|
'settings',
|
||||||
t('nav_group_management'),
|
t('txt_settings'),
|
||||||
<ShieldUser size={16} />,
|
<SettingsIcon size={16} />,
|
||||||
managementActive,
|
|
||||||
<>
|
<>
|
||||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
||||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
|
||||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||||
|
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAdmin &&
|
||||||
|
renderNavGroup(
|
||||||
|
'management',
|
||||||
|
t('nav_group_system_management'),
|
||||||
|
<ShieldUser size={16} />,
|
||||||
|
<>
|
||||||
|
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
|
||||||
|
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||||
|
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -277,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<aside className="app-side">
|
<aside className="app-side">
|
||||||
<div className="side-nav-main">
|
<div className="side-nav-main">
|
||||||
{navLayoutMode === 'flat' ? flatNav : groupedNav}
|
{groupedNav}
|
||||||
</div>
|
|
||||||
<div className="nav-layout-control" ref={navLayoutPickerRef}>
|
|
||||||
{navLayoutPickerOpen && (
|
|
||||||
<div className="nav-layout-menu" role="menu">
|
|
||||||
{navLayoutOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.mode}
|
|
||||||
type="button"
|
|
||||||
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
|
|
||||||
onClick={() => setNavMode(option.mode)}
|
|
||||||
role="menuitemradio"
|
|
||||||
aria-checked={navLayoutMode === option.mode}
|
|
||||||
>
|
|
||||||
<span className="nav-layout-option-text">
|
|
||||||
<strong>{option.label}</strong>
|
|
||||||
</span>
|
|
||||||
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={navLayoutPickerOpen}
|
|
||||||
onClick={() => setNavLayoutPickerOpen((open) => !open)}
|
|
||||||
title={t('txt_nav_layout')}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<main className="content">
|
||||||
@@ -327,6 +231,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<Clock3 size={18} />
|
<Clock3 size={18} />
|
||||||
<span>{t('txt_verification_code')}</span>
|
<span>{t('txt_verification_code')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
<span>{t('nav_generator')}</span>
|
||||||
|
</Link>
|
||||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||||
<SendIcon size={18} />
|
<SendIcon size={18} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -11,7 +12,9 @@ export interface AppConfirmState {
|
|||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
hideCancel?: boolean;
|
hideCancel?: boolean;
|
||||||
onConfirm: () => void;
|
/** When true, dialog shows a master-password field and passes it to onConfirm. */
|
||||||
|
requireMasterPassword?: boolean;
|
||||||
|
onConfirm: (masterPassword?: string) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +24,14 @@ interface AppGlobalOverlaysProps {
|
|||||||
confirm: AppConfirmState | null;
|
confirm: AppConfirmState | null;
|
||||||
onCancelConfirm: () => void;
|
onCancelConfirm: () => void;
|
||||||
pendingTotpOpen: boolean;
|
pendingTotpOpen: boolean;
|
||||||
|
pendingTotpProviderType?: number;
|
||||||
|
pendingTotpAvailableProviders?: number[];
|
||||||
totpCode: string;
|
totpCode: string;
|
||||||
rememberDevice: boolean;
|
rememberDevice: boolean;
|
||||||
onTotpCodeChange: (value: string) => void;
|
onTotpCodeChange: (value: string) => void;
|
||||||
onRememberDeviceChange: (checked: boolean) => void;
|
onRememberDeviceChange: (checked: boolean) => void;
|
||||||
onConfirmTotp: () => void;
|
onConfirmTotp: () => void;
|
||||||
|
onSelectTotpProvider: (providerType: number) => void;
|
||||||
onCancelTotp: () => void;
|
onCancelTotp: () => void;
|
||||||
onUseRecoveryCode: () => void;
|
onUseRecoveryCode: () => void;
|
||||||
totpSubmitting: boolean;
|
totpSubmitting: boolean;
|
||||||
@@ -37,7 +43,46 @@ interface AppGlobalOverlaysProps {
|
|||||||
disableTotpSubmitting: boolean;
|
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) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -49,16 +94,43 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={props.confirm?.confirmText}
|
confirmText={props.confirm?.confirmText}
|
||||||
cancelText={props.confirm?.cancelText}
|
cancelText={props.confirm?.cancelText}
|
||||||
hideCancel={props.confirm?.hideCancel}
|
hideCancel={props.confirm?.hideCancel}
|
||||||
onConfirm={() => props.confirm?.onConfirm()}
|
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
|
||||||
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
|
onConfirm={() => {
|
||||||
|
if (requireMasterPassword && !confirmPassword.trim()) return;
|
||||||
|
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
|
||||||
|
setConfirmPassword('');
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmPassword('');
|
||||||
|
(props.confirm?.onCancel || props.onCancelConfirm)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requireMasterPassword && (
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.pendingTotpOpen}
|
open={props.pendingTotpOpen}
|
||||||
title={t('txt_two_step_verification')}
|
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
|
||||||
message={t('txt_password_is_already_verified')}
|
<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')}
|
confirmText={t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
hideCancel
|
||||||
|
closeButton
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
confirmDisabled={props.totpSubmitting}
|
confirmDisabled={props.totpSubmitting}
|
||||||
cancelDisabled={props.totpSubmitting}
|
cancelDisabled={props.totpSubmitting}
|
||||||
@@ -67,16 +139,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
afterActions={(
|
afterActions={(
|
||||||
<div className="dialog-extra">
|
<div className="dialog-extra">
|
||||||
<div className="dialog-divider" />
|
<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}>
|
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||||
{t('txt_use_recovery_code')}
|
{t('txt_use_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isWebAuthn ? (
|
||||||
|
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||||
|
) : (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_totp_code')}</span>
|
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
<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>
|
||||||
|
)}
|
||||||
<label className="check-line check-line-compact">
|
<label className="check-line check-line-compact">
|
||||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
<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>
|
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||||
@@ -88,7 +196,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
title={t('txt_disable_totp')}
|
title={t('txt_disable_totp')}
|
||||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||||
confirmText={t('txt_disable_totp')}
|
confirmText={t('txt_disable_totp')}
|
||||||
cancelText={t('txt_cancel')}
|
hideCancel
|
||||||
|
closeButton
|
||||||
danger
|
danger
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
confirmDisabled={props.disableTotpSubmitting}
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||||
import type { ExportRequest } from '@/lib/export-formats';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
|
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
|
||||||
|
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
@@ -39,6 +41,7 @@ export interface AppMainRoutesProps {
|
|||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
mobileLayout: boolean;
|
mobileLayout: boolean;
|
||||||
mobileSidebarToggleKey: number;
|
mobileSidebarToggleKey: number;
|
||||||
|
themePreference: 'system' | 'light' | 'dark';
|
||||||
importRoute: string;
|
importRoute: string;
|
||||||
settingsHomeRoute: string;
|
settingsHomeRoute: string;
|
||||||
settingsAccountRoute: string;
|
settingsAccountRoute: string;
|
||||||
@@ -54,9 +57,12 @@ export interface AppMainRoutesProps {
|
|||||||
adminLoading: boolean;
|
adminLoading: boolean;
|
||||||
adminError: string;
|
adminError: string;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
|
yubikeyEnabled: boolean;
|
||||||
|
passkey2faEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
|
currentDeviceIdentifier: string;
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
authorizedDevicesError: string;
|
authorizedDevicesError: string;
|
||||||
domainRules: DomainRules | null;
|
domainRules: DomainRules | null;
|
||||||
@@ -65,6 +71,7 @@ export interface AppMainRoutesProps {
|
|||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
|
||||||
onImport: (
|
onImport: (
|
||||||
payload: CiphersImportPayload,
|
payload: CiphersImportPayload,
|
||||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
@@ -107,8 +114,17 @@ export interface AppMainRoutesProps {
|
|||||||
sendUploadPercent: number | null;
|
sendUploadPercent: number | null;
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: 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;
|
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>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
@@ -116,8 +132,10 @@ export interface AppMainRoutesProps {
|
|||||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||||
|
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing: boolean;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
@@ -130,30 +148,32 @@ export interface AppMainRoutesProps {
|
|||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||||
onRevokeAllDeviceTrust: () => void;
|
onRevokeAllDeviceTrust: () => void;
|
||||||
onRemoveAllDevices: () => void;
|
onRemoveAllDevices: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onRefreshAdmin: () => void;
|
onRefreshAdmin: () => void;
|
||||||
|
onDeleteInvalidInvites: () => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onDeleteInvite: (code: string) => Promise<void>;
|
||||||
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
||||||
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
||||||
onClearAuditLogs: () => Promise<number>;
|
onClearAuditLogs: () => Promise<number>;
|
||||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (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) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
@@ -189,6 +209,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/security/password-health">
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
<Route path="/generator">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<PasswordGeneratorPage />
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
<Route path="/sends">
|
<Route path="/sends">
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<SendsPage
|
<SendsPage
|
||||||
@@ -262,12 +302,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
profile={props.profile}
|
profile={props.profile}
|
||||||
totpEnabled={props.totpEnabled}
|
totpEnabled={props.totpEnabled}
|
||||||
|
yubikeyEnabled={props.yubikeyEnabled}
|
||||||
|
passkey2faEnabled={props.passkey2faEnabled}
|
||||||
|
themePreference={props.themePreference}
|
||||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||||
|
onThemePreferenceChange={props.onThemePreferenceChange}
|
||||||
|
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||||
onChangePassword={props.onChangePassword}
|
onChangePassword={props.onChangePassword}
|
||||||
onSavePasswordHint={props.onSavePasswordHint}
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
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}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
onGetApiKey={props.onGetApiKey}
|
onGetApiKey={props.onGetApiKey}
|
||||||
onRotateApiKey={props.onRotateApiKey}
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
@@ -275,11 +329,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
@@ -292,7 +342,22 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{props.profile ? (
|
{props.profile ? (
|
||||||
<section className="card mobile-settings-card">
|
<section className="card mobile-settings-card settings-home-card">
|
||||||
|
<div className="settings-home-section">
|
||||||
|
<h3>{t('nav_group_tools')}</h3>
|
||||||
|
<div className="mobile-settings-links">
|
||||||
|
<Link href="/security/password-health" className="mobile-settings-link">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<span>{t('nav_password_security')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
|
<ArrowUpDown size={18} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-home-section">
|
||||||
|
<h3>{t('txt_settings')}</h3>
|
||||||
<div className="mobile-settings-links">
|
<div className="mobile-settings-links">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
@@ -306,29 +371,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Globe2 size={18} />
|
<Globe2 size={18} />
|
||||||
<span>{t('nav_domain_rules')}</span>
|
<span>{t('nav_domain_rules')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={props.importRoute} className="mobile-settings-link">
|
</div>
|
||||||
<ArrowUpDown size={18} />
|
</div>
|
||||||
<span>{t('nav_import_export')}</span>
|
|
||||||
</Link>
|
|
||||||
{isAdmin && (
|
|
||||||
<Link href="/admin" className="mobile-settings-link">
|
|
||||||
<ShieldUser size={18} />
|
|
||||||
<span>{t('nav_admin_panel')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Link href="/logs" className="mobile-settings-link">
|
|
||||||
<FileClock size={18} />
|
|
||||||
<span>{t('nav_log_center')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<div className="settings-home-section">
|
||||||
|
<h3>{t('nav_group_system_management')}</h3>
|
||||||
|
<div className="mobile-settings-links">
|
||||||
<Link href="/backup" className="mobile-settings-link">
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
<Cloud size={18} />
|
<Cloud size={18} />
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
<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>
|
||||||
|
)}
|
||||||
|
<div className="settings-home-spacer" />
|
||||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||||
<LogOut size={14} className="btn-icon" />
|
<LogOut size={14} className="btn-icon" />
|
||||||
{t('txt_sign_out')}
|
{t('txt_sign_out')}
|
||||||
@@ -352,10 +416,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<SecurityDevicesPage
|
<SecurityDevicesPage
|
||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
|
currentDeviceIdentifier={props.currentDeviceIdentifier}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
error={props.authorizedDevicesError}
|
error={props.authorizedDevicesError}
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
@@ -364,6 +430,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
onTrustPermanently={props.onTrustDevicePermanently}
|
onTrustPermanently={props.onTrustDevicePermanently}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
|
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
onRemoveAll={props.onRemoveAllDevices}
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
/>
|
/>
|
||||||
@@ -412,10 +479,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
error={props.adminError}
|
error={props.adminError}
|
||||||
onRefresh={props.onRefreshAdmin}
|
onRefresh={props.onRefreshAdmin}
|
||||||
onCreateInvite={props.onCreateInvite}
|
onCreateInvite={props.onCreateInvite}
|
||||||
|
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
|
||||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
onToggleUserStatus={props.onToggleUserStatus}
|
onToggleUserStatus={props.onToggleUserStatus}
|
||||||
onDeleteUser={props.onDeleteUser}
|
onDeleteUser={props.onDeleteUser}
|
||||||
onRevokeInvite={props.onRevokeInvite}
|
onDeleteInvite={props.onDeleteInvite}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
|
||||||
|
|
||||||
interface LoginValues {
|
interface LoginValues {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -26,6 +27,7 @@ interface AuthViewsProps {
|
|||||||
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
unlockPreparing: boolean;
|
unlockPreparing: boolean;
|
||||||
|
sessionRefreshError?: string;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
pendingPasskeyPasswordEmail?: string | null;
|
pendingPasskeyPasswordEmail?: string | null;
|
||||||
passkeyPassword: string;
|
passkeyPassword: string;
|
||||||
@@ -49,6 +51,7 @@ interface AuthViewsProps {
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onTogglePasswordHint: () => void;
|
onTogglePasswordHint: () => void;
|
||||||
onShowLockedPasswordHint: () => void;
|
onShowLockedPasswordHint: () => void;
|
||||||
|
onRetrySessionRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordField(props: {
|
function PasswordField(props: {
|
||||||
@@ -81,6 +84,36 @@ function PasswordField(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OfflineModeNotice() {
|
||||||
|
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||||
|
|
||||||
|
useEffect(() => subscribeNetworkStatus(setStatus), []);
|
||||||
|
|
||||||
|
if (status !== 'offline') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="offline-mode-notice" role="alert" aria-live="assertive">
|
||||||
|
<div>
|
||||||
|
<strong>{t('txt_offline_mode_notice_title')}</strong>
|
||||||
|
<div className="offline-shortcut-list">
|
||||||
|
<div className="offline-shortcut-row">
|
||||||
|
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
|
||||||
|
<span className="offline-shortcut-value">
|
||||||
|
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="offline-shortcut-row">
|
||||||
|
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
|
||||||
|
<span className="offline-shortcut-value">
|
||||||
|
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthViews(props: AuthViewsProps) {
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
const passkeyBusy = props.pendingAction === 'passkey';
|
const passkeyBusy = props.pendingAction === 'passkey';
|
||||||
@@ -99,6 +132,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitUnlock();
|
props.onSubmitUnlock();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
<PasswordField
|
<PasswordField
|
||||||
@@ -123,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
{props.unlockPreparing ? (
|
{props.unlockPreparing ? (
|
||||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{props.sessionRefreshError ? (
|
||||||
|
<div className="offline-mode-notice" role="alert" aria-live="polite">
|
||||||
|
<AlertTriangle size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>{props.sessionRefreshError}</strong>
|
||||||
|
<div>
|
||||||
|
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
|
||||||
<Unlock size={16} className="btn-icon" />
|
<Unlock size={16} className="btn-icon" />
|
||||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||||
@@ -245,6 +292,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitLogin();
|
props.onSubmitLogin();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
{passkeyPasswordPending ? (
|
{passkeyPasswordPending ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||||
|
|||||||
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
|||||||
|
|
||||||
interface BackupCenterPageProps {
|
interface BackupCenterPageProps {
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (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;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +53,16 @@ type PendingRestoreIntegrity =
|
|||||||
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||||
| { source: 'remote'; fileName: string; path: 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 {
|
interface BackupProgressPhase {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
detailKey: string;
|
detailKey: string;
|
||||||
@@ -184,7 +194,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [, setLocalError] = useState('');
|
||||||
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||||
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||||
@@ -193,6 +203,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = 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 [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||||
@@ -209,7 +223,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
||||||
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
||||||
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
||||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
|
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
|
||||||
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
||||||
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
||||||
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
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 recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
||||||
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
||||||
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -448,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||||
return {
|
return {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
path,
|
path,
|
||||||
@@ -478,10 +522,15 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
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);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const saved = await props.onSaveSettings(nextSettings);
|
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||||
setSavedSettings(saved);
|
setSavedSettings(saved);
|
||||||
@@ -497,27 +546,32 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSelectedDestinationId(nextSelected);
|
setSelectedDestinationId(nextSelected);
|
||||||
setConfirmDeleteDestinationOpen(false);
|
setConfirmDeleteDestinationOpen(false);
|
||||||
props.onNotify('success', t('txt_backup_destination_deleted'));
|
props.onNotify('success', t('txt_backup_destination_deleted'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
|
if (exporting) return;
|
||||||
|
openBackupPasswordPrompt({ action: 'export' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeExport(masterPassword: string): Promise<boolean> {
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
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'));
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
showActionError(error, t('txt_backup_export_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -535,6 +589,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
return;
|
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('');
|
setLocalError('');
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
@@ -547,8 +623,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
});
|
});
|
||||||
const result = allowChecksumMismatch
|
const result = allowChecksumMismatch
|
||||||
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
|
||||||
: await props.onImport(selectedFile, replaceExisting);
|
: await props.onImport(masterPassword, selectedFile, replaceExisting);
|
||||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
@@ -556,29 +632,34 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
clearRestoreProgress();
|
clearRestoreProgress();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(true);
|
setConfirmReplaceOpen(true);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
showActionError(error, t('txt_backup_restore_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveSettings() {
|
async function handleSaveSettings() {
|
||||||
|
if (savingSettings) return;
|
||||||
|
openBackupPasswordPrompt({ action: 'saveSettings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
|
||||||
const payload = buildSettingsPayloadForSelectedDestination();
|
const payload = buildSettingsPayloadForSelectedDestination();
|
||||||
const destinationIdToInvalidate = selectedDestinationId;
|
const destinationIdToInvalidate = selectedDestinationId;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const saved = await props.onSaveSettings(payload);
|
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||||
const nextSelected =
|
const nextSelected =
|
||||||
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
||||||
|| getFirstVisibleDestinationId(saved)
|
|| getFirstVisibleDestinationId(saved)
|
||||||
@@ -592,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
setSelectedDestinationId(nextSelected);
|
setSelectedDestinationId(nextSelected);
|
||||||
props.onNotify('success', t('txt_backup_settings_saved'));
|
props.onNotify('success', t('txt_backup_settings_saved'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
@@ -613,7 +694,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRunRemoteBackup() {
|
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);
|
setRunningRemoteBackup(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
@@ -621,33 +707,37 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
source: 'remote',
|
source: 'remote',
|
||||||
includeAttachments: !!selectedDestination.includeAttachments,
|
includeAttachments: !!selectedDestination.includeAttachments,
|
||||||
});
|
});
|
||||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
|
||||||
setSavedSettings(result.settings);
|
setSavedSettings(result.settings);
|
||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
showActionError(error, t('txt_backup_remote_run_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setRunningRemoteBackup(false);
|
setRunningRemoteBackup(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadRemote(path: string) {
|
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);
|
setDownloadingRemotePath(path);
|
||||||
setDownloadingRemotePercent(null);
|
setDownloadingRemotePercent(null);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
showActionError(error, t('txt_backup_remote_download_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingRemotePath('');
|
setDownloadingRemotePath('');
|
||||||
setDownloadingRemotePercent(null);
|
setDownloadingRemotePercent(null);
|
||||||
@@ -657,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
async function handleDeleteRemote(path: string) {
|
async function handleDeleteRemote(path: string) {
|
||||||
if (deletingRemotePath) return;
|
if (deletingRemotePath) return;
|
||||||
if (!savedSelectedDestination) 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);
|
setDeletingRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
|
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
|
||||||
setConfirmRemoteDeleteOpen(false);
|
setConfirmRemoteDeleteOpen(false);
|
||||||
setPendingRemoteDeletePath('');
|
setPendingRemoteDeletePath('');
|
||||||
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
|
showActionError(error, t('txt_backup_remote_delete_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingRemotePath('');
|
setDeletingRemotePath('');
|
||||||
}
|
}
|
||||||
@@ -704,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
try {
|
await runRemoteRestore(path, false);
|
||||||
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
|
||||||
if (!integrity.result.matches) {
|
|
||||||
setPendingRestoreIntegrity(integrity);
|
|
||||||
setConfirmIntegrityWarningOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runRemoteRestore(path, false, false, integrity.result);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRemoteRestore(
|
async function runRemoteRestore(
|
||||||
@@ -727,41 +811,114 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
) {
|
) {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return;
|
||||||
if (!savedSelectedDestination) 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);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setConfirmIntegrityWarningOpen(false);
|
setConfirmIntegrityWarningOpen(false);
|
||||||
setRestoringRemotePath(path);
|
setRestoringRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
const integrity = knownIntegrity
|
||||||
|
? { result: knownIntegrity }
|
||||||
|
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
|
||||||
|
if (!allowChecksumMismatch && !integrity.result.matches) {
|
||||||
|
setPendingRestoreIntegrity(
|
||||||
|
'source' in integrity
|
||||||
|
? integrity
|
||||||
|
: {
|
||||||
|
source: 'remote',
|
||||||
|
path,
|
||||||
|
fileName: path.split('/').pop() || path,
|
||||||
|
result: integrity.result,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setConfirmIntegrityWarningOpen(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
});
|
});
|
||||||
const result = allowChecksumMismatch
|
const result = allowChecksumMismatch
|
||||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
|
||||||
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
: await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
setPendingRemoteRestorePath(path);
|
setPendingRemoteRestorePath(path);
|
||||||
setConfirmRemoteReplaceOpen(true);
|
setConfirmRemoteReplaceOpen(true);
|
||||||
clearRestoreProgress();
|
clearRestoreProgress();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
showActionError(error, t('txt_backup_remote_restore_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setRestoringRemotePath('');
|
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 (
|
return (
|
||||||
<div className="backup-grid">
|
<div className="backup-grid">
|
||||||
<input
|
<input
|
||||||
@@ -848,7 +1005,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{localError ? <div className="local-error">{localError}</div> : null}
|
|
||||||
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||||
<div className="restore-progress-overlay" aria-live="polite">
|
<div className="restore-progress-overlay" aria-live="polite">
|
||||||
<section className="restore-progress-card restore-progress-modal">
|
<section className="restore-progress-card restore-progress-modal">
|
||||||
@@ -893,6 +1049,43 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
), document.body) : null}
|
), 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
|
<ConfirmDialog
|
||||||
open={confirmLocalRestoreOpen}
|
open={confirmLocalRestoreOpen}
|
||||||
title={t('txt_backup_import')}
|
title={t('txt_backup_import')}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { TriangleAlert } from 'lucide-preact';
|
import { TriangleAlert, X } from 'lucide-preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: ComponentChildren;
|
||||||
message: string;
|
message?: string;
|
||||||
variant?: 'default' | 'warning';
|
variant?: 'default' | 'warning';
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
hideCancel?: boolean;
|
hideCancel?: boolean;
|
||||||
|
hideConfirm?: boolean;
|
||||||
|
closeButton?: boolean;
|
||||||
confirmDisabled?: boolean;
|
confirmDisabled?: boolean;
|
||||||
cancelDisabled?: boolean;
|
cancelDisabled?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -85,13 +87,16 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
const maskPointerStartedRef = useRef(false);
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
const messageId = `${dialogId}-message`;
|
const messageId = `${dialogId}-message`;
|
||||||
|
const hasMessage = !!props.message;
|
||||||
const canDismiss = !props.cancelDisabled && !closing;
|
const canDismiss = !props.cancelDisabled && !closing;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
|
lastTitleRef.current = props.title;
|
||||||
setPresent(true);
|
setPresent(true);
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
return;
|
return;
|
||||||
@@ -191,7 +196,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={messageId}
|
aria-describedby={hasMessage ? messageId : undefined}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onKeyDown={handleDialogKeyDown}
|
onKeyDown={handleDialogKeyDown}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -211,9 +216,24 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
{props.closeButton && (
|
||||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dialog-close-btn"
|
||||||
|
aria-label={t('txt_close')}
|
||||||
|
disabled={props.cancelDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (props.cancelDisabled) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
|
||||||
|
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||||
{props.children}
|
{props.children}
|
||||||
|
{!props.hideConfirm && (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||||
@@ -222,6 +242,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
>
|
>
|
||||||
{props.confirmText || t('txt_yes')}
|
{props.confirmText || t('txt_yes')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{!props.hideCancel && (
|
{!props.hideCancel && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
import { createPortal } from 'preact/compat';
|
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 { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
|||||||
'keepassx_csv',
|
'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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
@@ -171,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
|
|||||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
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 {
|
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);
|
const fileNames = Object.keys(unzipped);
|
||||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
|
||||||
@@ -189,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
|||||||
|
|
||||||
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||||
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||||
|
assertImportTextFileSize(file.size);
|
||||||
return file.text();
|
return file.text();
|
||||||
}
|
}
|
||||||
|
assertImportZipSize(file.size);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||||
|
assertImportTextFileSize(bytes.byteLength);
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
|
|||||||
return message.includes('password') || message.includes('encrypted');
|
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(
|
async function readBitwardenZipPayload(
|
||||||
file: File,
|
file: File,
|
||||||
passwordRaw: string
|
passwordRaw: string
|
||||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||||
const password = String(passwordRaw || '').trim();
|
const password = String(passwordRaw || '').trim();
|
||||||
|
assertImportZipSize(file.size);
|
||||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||||
try {
|
try {
|
||||||
const entries = await reader.getEntries();
|
const entries = await reader.getEntries();
|
||||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
validateBitwardenZipEntries(entries);
|
||||||
|
|
||||||
let jsonText = '';
|
let jsonText = '';
|
||||||
|
let totalAttachmentBytes = 0;
|
||||||
const attachments: ImportAttachmentFile[] = [];
|
const attachments: ImportAttachmentFile[] = [];
|
||||||
const options = password ? { password } : undefined;
|
const options = password ? { password } : undefined;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.directory) continue;
|
if (entry.directory) continue;
|
||||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
const name = zipEntryName(entry.filename);
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
assertSafeZipEntryName(name);
|
||||||
|
|
||||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
if (lower === 'data.json') {
|
if (lower === 'data.json') {
|
||||||
|
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||||
jsonText = new TextDecoder().decode(bytes);
|
jsonText = new TextDecoder().decode(bytes);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
const attachmentMatch = bitwardenZipAttachmentMatch(name);
|
||||||
if (!attachmentMatch) continue;
|
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 sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||||
attachments.push({
|
attachments.push({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface JwtWarningPageProps {
|
interface JwtWarningPageProps {
|
||||||
reason: 'missing' | 'default' | 'too_short';
|
reason: 'missing' | 'too_short';
|
||||||
minLength: number;
|
minLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +21,6 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
const title =
|
const title =
|
||||||
props.reason === 'missing'
|
props.reason === 'missing'
|
||||||
? t('txt_jwt_title_missing')
|
? t('txt_jwt_title_missing')
|
||||||
: props.reason === 'default'
|
|
||||||
? t('txt_jwt_title_default')
|
|
||||||
: t('txt_jwt_title_too_short');
|
: t('txt_jwt_title_too_short');
|
||||||
|
|
||||||
const isMissing = props.reason === 'missing';
|
const isMissing = props.reason === 'missing';
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
|
|||||||
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
|
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTargetType(type: string): string {
|
||||||
|
return translatedOrHumanized(keyFor('txt_log_target_type_', type), type);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(value: string): string {
|
function formatTime(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
@@ -148,11 +152,16 @@ function formatMetaValueForKey(key: string, value: unknown): string {
|
|||||||
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
|
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
|
||||||
}
|
}
|
||||||
if (key === 'type' && typeof value === 'string') {
|
if (key === 'type' && typeof value === 'string') {
|
||||||
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
|
return formatTargetType(value);
|
||||||
}
|
}
|
||||||
return formatMetaValue(value);
|
return formatMetaValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLogTarget(log: AuditLogEntry, metadata: Record<string, unknown>): string {
|
||||||
|
const targetEmail = typeof metadata.targetEmail === 'string' ? metadata.targetEmail : '';
|
||||||
|
return log.targetUserEmail || targetEmail || log.targetId || (log.targetType ? formatTargetType(log.targetType) : t('txt_dash'));
|
||||||
|
}
|
||||||
|
|
||||||
function iconForCategory(category: AuditLogCategory) {
|
function iconForCategory(category: AuditLogCategory) {
|
||||||
if (category === 'auth') return <ShieldAlert size={16} />;
|
if (category === 'auth') return <ShieldAlert size={16} />;
|
||||||
if (category === 'security') return <UserRound size={16} />;
|
if (category === 'security') return <UserRound size={16} />;
|
||||||
@@ -550,7 +559,7 @@ export default function LogCenterPage(props: LogCenterPageProps) {
|
|||||||
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
|
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
|
||||||
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
|
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
|
||||||
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
|
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
|
||||||
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div>
|
<div><span>{t('txt_target')}</span><strong>{formatLogTarget(selectedLog, selectedMetadata)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="log-detail-json">
|
<div className="log-detail-json">
|
||||||
<h4>{t('txt_metadata')}</h4>
|
<h4>{t('txt_metadata')}</h4>
|
||||||
|
|||||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||||
const starBoxes = [1, 2, 3, 4];
|
|
||||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="not-found-page">
|
<main className="not-found-page">
|
||||||
<div className="not-found-space" aria-hidden="true">
|
|
||||||
{starBoxes.map((box) => (
|
|
||||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
|
||||||
{stars.map((star) => (
|
|
||||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||||
<div className="not-found-brand">
|
<div className="not-found-brand">
|
||||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="not-found-astro-stage" aria-hidden="true">
|
|
||||||
<div className="not-found-astronaut">
|
|
||||||
<div className="not-found-astro-head" />
|
|
||||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
|
||||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
|
||||||
<div className="not-found-astro-body">
|
|
||||||
<div className="not-found-astro-panel" />
|
|
||||||
</div>
|
|
||||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
|
||||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
|
||||||
<div className="not-found-astro-pack" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="not-found-copy">
|
<div className="not-found-copy">
|
||||||
<div className="not-found-code">404</div>
|
<div className="not-found-code">404</div>
|
||||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type GeneratorMode = 'password' | 'passphrase';
|
||||||
|
|
||||||
|
interface PasswordOptions {
|
||||||
|
length: number;
|
||||||
|
uppercase: boolean;
|
||||||
|
lowercase: boolean;
|
||||||
|
numbers: boolean;
|
||||||
|
special: boolean;
|
||||||
|
minNumbers: number;
|
||||||
|
minSpecial: number;
|
||||||
|
avoidAmbiguous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PassphraseOptions {
|
||||||
|
words: number;
|
||||||
|
separator: string;
|
||||||
|
capitalize: boolean;
|
||||||
|
includeNumber: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||||
|
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const DIGITS = '0123456789';
|
||||||
|
const SPECIAL = '!@#$%^&*';
|
||||||
|
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||||
|
|
||||||
|
const defaultPasswordOptions: PasswordOptions = {
|
||||||
|
length: 14,
|
||||||
|
uppercase: true,
|
||||||
|
lowercase: true,
|
||||||
|
numbers: true,
|
||||||
|
special: false,
|
||||||
|
minNumbers: 1,
|
||||||
|
minSpecial: 1,
|
||||||
|
avoidAmbiguous: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPassphraseOptions: PassphraseOptions = {
|
||||||
|
words: 6,
|
||||||
|
separator: '-',
|
||||||
|
capitalize: false,
|
||||||
|
includeNumber: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||||
|
return {
|
||||||
|
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||||
|
password: {
|
||||||
|
...defaultPasswordOptions,
|
||||||
|
...stored.password,
|
||||||
|
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||||
|
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||||
|
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||||
|
},
|
||||||
|
passphrase: {
|
||||||
|
...defaultPassphraseOptions,
|
||||||
|
...stored.passphrase,
|
||||||
|
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||||
|
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomIndex(length: number): number {
|
||||||
|
const range = 0x1_0000_0000;
|
||||||
|
const upperBound = Math.floor(range / length) * length;
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||||
|
return buffer[0] % length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(characters: string): string {
|
||||||
|
return characters[randomIndex(characters.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(value: string[]): string[] {
|
||||||
|
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||||
|
const next = randomIndex(index + 1);
|
||||||
|
[value[index], value[next]] = [value[next], value[index]];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||||
|
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePassword(options: PasswordOptions): string {
|
||||||
|
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||||
|
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||||
|
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||||
|
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
|
||||||
|
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||||
|
const length = Math.max(options.length, minimumLength, 5);
|
||||||
|
const allCharacters = sets.map((set) => set.chars).join('');
|
||||||
|
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||||
|
while (characters.length < length) characters.push(pick(allCharacters));
|
||||||
|
return shuffle(characters).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePassphrase(options: PassphraseOptions): string {
|
||||||
|
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
||||||
|
if (options.capitalize) {
|
||||||
|
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||||
|
}
|
||||||
|
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||||
|
return words.join(options.separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||||
|
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||||
|
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordGeneratorPage() {
|
||||||
|
const initial = useMemo(readSettings, []);
|
||||||
|
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||||
|
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||||
|
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||||
|
const [seed, setSeed] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const generated = useMemo(
|
||||||
|
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||||
|
[mode, passwordOptions, passphraseOptions, seed]
|
||||||
|
);
|
||||||
|
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||||
|
} catch {
|
||||||
|
// The generator remains fully usable when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [mode, passwordOptions, passphraseOptions]);
|
||||||
|
|
||||||
|
const regenerate = () => {
|
||||||
|
setCopied(false);
|
||||||
|
setSeed((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||||
|
window.setTimeout(() => setCopied(false), 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||||
|
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||||
|
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||||
|
<div className="generator-layout">
|
||||||
|
<section className="generator-output-card" aria-live="polite">
|
||||||
|
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||||
|
</div>
|
||||||
|
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||||
|
<div className="generator-strength-row">
|
||||||
|
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||||
|
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||||
|
</div>
|
||||||
|
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="actions generator-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||||
|
</div>
|
||||||
|
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||||
|
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||||
|
{mode === 'password' ? (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||||
|
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||||
|
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||||
|
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||||
|
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||||
|
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||||
|
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||||
|
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||||
|
</fieldset>
|
||||||
|
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||||
|
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||||
|
<div className="generator-option-group">
|
||||||
|
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||||
|
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||||
|
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||||
|
const id = `generator-stepper-${props.id}`;
|
||||||
|
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||||
|
return (
|
||||||
|
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||||
|
<label htmlFor={id}>{props.label}</label>
|
||||||
|
<div className="generator-stepper">
|
||||||
|
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
|
||||||
|
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
|
||||||
|
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
|
import { maskSecret } from '@/components/vault/vault-page-helpers';
|
||||||
|
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
|
||||||
|
interface PasswordSecurityPageProps {
|
||||||
|
ciphers: Cipher[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
|
||||||
|
|
||||||
|
function vaultFingerprint(ciphers: Cipher[]): string {
|
||||||
|
return JSON.stringify(ciphers.map((cipher) => ({
|
||||||
|
id: cipher.id,
|
||||||
|
type: cipher.type,
|
||||||
|
revisionDate: cipher.revisionDate || '',
|
||||||
|
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheckedAt(value: number): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
|
||||||
|
const fingerprint = vaultFingerprint(props.ciphers);
|
||||||
|
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
|
||||||
|
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
|
||||||
|
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
setSecurityState(getPasswordSecurityState(fingerprint));
|
||||||
|
setFilter('all');
|
||||||
|
setRevealedPasswordIds(new Set());
|
||||||
|
return subscribePasswordSecurityState(() => {
|
||||||
|
const next = readPasswordSecurityState(fingerprint);
|
||||||
|
if (next) setSecurityState(next);
|
||||||
|
});
|
||||||
|
}, [fingerprint]);
|
||||||
|
|
||||||
|
const { report, scannedAt, scanning, progress, scanError } = securityState;
|
||||||
|
|
||||||
|
const eligibleCount = useMemo(
|
||||||
|
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
|
||||||
|
[props.ciphers],
|
||||||
|
);
|
||||||
|
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!report || filter === 'all') return report?.items || [];
|
||||||
|
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
|
||||||
|
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
|
||||||
|
return report.items.filter((item) => item.weak);
|
||||||
|
}, [filter, report]);
|
||||||
|
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
|
||||||
|
|
||||||
|
const togglePasswordVisibility = (cipherId: string) => {
|
||||||
|
setRevealedPasswordIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(cipherId)) next.delete(cipherId);
|
||||||
|
else next.add(cipherId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllPasswordVisibility = () => {
|
||||||
|
if (!report) return;
|
||||||
|
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scan = () => {
|
||||||
|
setRevealedPasswordIds(new Set());
|
||||||
|
setFilter('all');
|
||||||
|
startPasswordSecurityScan(fingerprint, props.ciphers);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="password-security-page" aria-label={t('txt_password_security')}>
|
||||||
|
<div className="password-security-intro card">
|
||||||
|
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
|
||||||
|
<div>
|
||||||
|
<h2>{t('txt_password_security')}</h2>
|
||||||
|
<p>{t('txt_password_security_privacy')}</p>
|
||||||
|
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="password-security-intro-actions">
|
||||||
|
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
|
||||||
|
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
|
||||||
|
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
|
||||||
|
</button>}
|
||||||
|
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
|
||||||
|
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
|
||||||
|
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!report && !scanning && !props.loading && (
|
||||||
|
<div className="password-security-empty card">
|
||||||
|
<ShieldCheck size={26} aria-hidden="true" />
|
||||||
|
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
|
||||||
|
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(scanning || report) && (
|
||||||
|
<div className="password-security-summary" aria-live="polite">
|
||||||
|
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
|
||||||
|
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
|
||||||
|
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
|
||||||
|
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<section className="password-security-results card">
|
||||||
|
{report.unavailableCount > 0 && (
|
||||||
|
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
|
||||||
|
)}
|
||||||
|
{!report.items.length ? (
|
||||||
|
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
|
||||||
|
) : !filteredItems.length ? (
|
||||||
|
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
|
||||||
|
) : (
|
||||||
|
<div className="password-security-list">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const cipher = ciphersById.get(item.cipherId);
|
||||||
|
const name = String(cipher?.decName || cipher?.name || '');
|
||||||
|
const password = String(cipher?.login?.decPassword || '');
|
||||||
|
const passwordVisible = revealedPasswordIds.has(item.cipherId);
|
||||||
|
return <article className="password-security-item" key={item.cipherId}>
|
||||||
|
<div className="password-security-item-main">
|
||||||
|
<div className="password-security-item-header">
|
||||||
|
<strong>{name || t('txt_no_name')}</strong>
|
||||||
|
<div className="password-security-badges">
|
||||||
|
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
|
||||||
|
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
|
||||||
|
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
|
||||||
|
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="password-security-item-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
|
||||||
|
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
|
||||||
|
</button>
|
||||||
|
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
|
||||||
|
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
|
||||||
|
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { t } from '@/lib/i18n';
|
|||||||
interface PendingAuthRequestsPanelProps {
|
interface PendingAuthRequestsPanelProps {
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing?: boolean;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
@@ -22,6 +23,7 @@ function formatDateTime(value: string | null | undefined): string {
|
|||||||
|
|
||||||
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
|
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
|
||||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||||
|
const refreshing = props.pendingAuthRequestsLoading || !!props.pendingAuthRequestsRefreshing;
|
||||||
|
|
||||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
if (authRequestSubmittingId) return;
|
if (authRequestSubmittingId) return;
|
||||||
@@ -50,10 +52,10 @@ export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanel
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary small"
|
className="btn btn-secondary small"
|
||||||
disabled={props.pendingAuthRequestsLoading}
|
disabled={refreshing}
|
||||||
onClick={() => void props.onRefreshPendingAuthRequests()}
|
onClick={() => void props.onRefreshPendingAuthRequests()}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className={`btn-icon${refreshing ? ' btn-icon-spin' : ''}`} />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSendDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '';
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function optionalString(value: unknown): string | null {
|
function optionalString(value: unknown): string | null {
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
|
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||||
@@ -8,10 +8,12 @@ import { t } from '@/lib/i18n';
|
|||||||
|
|
||||||
interface SecurityDevicesPageProps {
|
interface SecurityDevicesPageProps {
|
||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
|
currentDeviceIdentifier: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
@@ -20,6 +22,7 @@ interface SecurityDevicesPageProps {
|
|||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onTrustPermanently: (device: AuthorizedDevice) => void;
|
onTrustPermanently: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
onRemoveAll: () => void;
|
onRemoveAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||||
const [deviceNote, setDeviceNote] = useState('');
|
const [deviceNote, setDeviceNote] = useState('');
|
||||||
const [savingNote, setSavingNote] = useState(false);
|
const [savingNote, setSavingNote] = useState(false);
|
||||||
|
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
|
||||||
|
const currentDeviceIdentifier = props.currentDeviceIdentifier;
|
||||||
|
const selectableDevices = props.devices.filter((device) => (
|
||||||
|
device.identifier !== currentDeviceIdentifier
|
||||||
|
));
|
||||||
|
const selectedDeviceIdSet = new Set(selectedDeviceIds);
|
||||||
|
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
|
||||||
|
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
|
||||||
|
|
||||||
async function handleSaveDeviceNote(): Promise<void> {
|
async function handleSaveDeviceNote(): Promise<void> {
|
||||||
if (!editingDevice || savingNote) return;
|
if (!editingDevice || savingNote) return;
|
||||||
@@ -75,6 +86,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSelectAllDevices(): void {
|
||||||
|
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectedDevice(device: AuthorizedDevice): void {
|
||||||
|
if (device.identifier === currentDeviceIdentifier) return;
|
||||||
|
setSelectedDeviceIds((current) => (
|
||||||
|
current.includes(device.identifier)
|
||||||
|
? current.filter((id) => id !== device.identifier)
|
||||||
|
: [...current, device.identifier]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -83,6 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
loadingVariant="compact"
|
loadingVariant="compact"
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
@@ -91,7 +116,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
<h3 className="flush-title">{t('txt_authorized_devices')}</h3>
|
||||||
<div className="muted-inline section-note">
|
<div className="muted-inline section-note">
|
||||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +126,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.loading || selectableDevices.length === 0}
|
||||||
|
onClick={toggleSelectAllDevices}
|
||||||
|
>
|
||||||
|
<CheckSquare size={14} className="btn-icon" />
|
||||||
|
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger small"
|
||||||
|
disabled={selectedDevices.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
props.onRemoveSelectedDevices(selectedDevices);
|
||||||
|
setSelectedDeviceIds([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_all_trusted')}
|
{t('txt_revoke_all_trusted')}
|
||||||
@@ -111,10 +157,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
|
||||||
{!!props.error && (
|
{!!props.error && (
|
||||||
<div className="local-error">
|
<div className="local-error">
|
||||||
<span>{props.error}</span>
|
<span>{props.error}</span>
|
||||||
@@ -126,6 +168,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
)}
|
)}
|
||||||
<table className="table authorized-devices-table">
|
<table className="table authorized-devices-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
<col className="authorized-devices-col-select" />
|
||||||
<col className="authorized-devices-col-device" />
|
<col className="authorized-devices-col-device" />
|
||||||
<col className="authorized-devices-col-type" />
|
<col className="authorized-devices-col-type" />
|
||||||
<col className="authorized-devices-col-status" />
|
<col className="authorized-devices-col-status" />
|
||||||
@@ -136,6 +179,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>{t('txt_select')}</th>
|
||||||
<th>{t('txt_device')}</th>
|
<th>{t('txt_device')}</th>
|
||||||
<th>{t('txt_type')}</th>
|
<th>{t('txt_type')}</th>
|
||||||
<th>{t('txt_status')}</th>
|
<th>{t('txt_status')}</th>
|
||||||
@@ -148,6 +192,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.devices.map((device) => (
|
{props.devices.map((device) => (
|
||||||
<tr key={device.identifier}>
|
<tr key={device.identifier}>
|
||||||
|
<td data-label={t('txt_select')}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="authorized-device-checkbox"
|
||||||
|
checked={selectedDeviceIdSet.has(device.identifier)}
|
||||||
|
disabled={device.identifier === currentDeviceIdentifier}
|
||||||
|
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
|
||||||
|
onChange={() => toggleSelectedDevice(device)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td data-label={t('txt_device')}>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||||
@@ -220,14 +274,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
))}
|
))}
|
||||||
{props.loading && props.devices.length === 0 && (
|
{props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={8}>
|
||||||
<LoadingState lines={5} compact />
|
<LoadingState lines={5} compact />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={8}>
|
||||||
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
|||||||
return String(Math.max(days, 0));
|
return String(Math.max(days, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSendDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultDraft(): SendDraft {
|
function buildDefaultDraft(): SendDraft {
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
|
|||||||
expirationDays: '0',
|
expirationDays: '0',
|
||||||
maxAccessCount: '',
|
maxAccessCount: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +67,7 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
expirationDays: daysFromNow(send.expirationDate, 0),
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: !!send.password,
|
||||||
disabled: !!send.disabled,
|
disabled: !!send.disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -380,6 +389,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="list-text">
|
<div className="list-text">
|
||||||
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
||||||
<span className="list-sub">
|
<span className="list-sub">
|
||||||
|
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
|
||||||
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password')}</span>
|
<span>{t('txt_password')}</span>
|
||||||
|
{draft.hasPassword ? (
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input className="input" type="password" value="••••••••" disabled />
|
||||||
|
{!isCreating && (
|
||||||
|
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="password-wrap">
|
<div className="password-wrap">
|
||||||
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_notes')}</span>
|
<span>{t('txt_notes')}</span>
|
||||||
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="card stagger-item stagger-delay-2">
|
<div className="card stagger-item stagger-delay-2">
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<h4>{t('txt_send_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
|||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOTP_PERIOD_SECONDS = 30;
|
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
|
|
||||||
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
|
|
||||||
interface TotpRowProps {
|
interface TotpRowProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
live: { code: string; remain: number } | null;
|
live: TotpCodeResult | null;
|
||||||
onCopy: (value: string) => void;
|
onCopy: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TotpRow(props: TotpRowProps) {
|
function TotpRow(props: TotpRowProps) {
|
||||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
const username = props.cipher.login?.decUsername || '';
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
const period = Math.max(1, props.live?.period || 30);
|
||||||
|
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-code-row">
|
<div className="totp-code-row">
|
||||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * progress
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let activeRun = 0;
|
let activeRun = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
let currentWindowId = -1;
|
|
||||||
|
|
||||||
const refreshCodes = async () => {
|
const refreshCodes = async () => {
|
||||||
const runId = ++activeRun;
|
const runId = ++activeRun;
|
||||||
const nextCodes: Record<string, string | null> = {};
|
const nextCodes: Record<string, TotpCodeResult | null> = {};
|
||||||
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
batch.map(async (cipher) => {
|
batch.map(async (cipher) => {
|
||||||
try {
|
try {
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
return [cipher.id, next?.code || null] as const;
|
return [cipher.id, next] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [cipher.id, null] as const;
|
return [cipher.id, null] as const;
|
||||||
}
|
}
|
||||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
setTotpCodes((prev) => {
|
setTotpCodes((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: Record<string, string | null> = { ...prev };
|
const next: Record<string, TotpCodeResult | null> = { ...prev };
|
||||||
for (const id of Object.keys(next)) {
|
for (const id of Object.keys(next)) {
|
||||||
if (id in nextCodes) continue;
|
if (id in nextCodes) continue;
|
||||||
delete next[id];
|
delete next[id];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
for (const [id, code] of Object.entries(nextCodes)) {
|
for (const [id, live] of Object.entries(nextCodes)) {
|
||||||
if (next[id] === code) continue;
|
const prevLive = next[id];
|
||||||
next[id] = code;
|
if (
|
||||||
|
prevLive?.code === live?.code &&
|
||||||
|
prevLive?.remain === live?.remain &&
|
||||||
|
prevLive?.period === live?.period
|
||||||
|
) continue;
|
||||||
|
next[id] = live;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const next = getTotpTimeState();
|
|
||||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
|
||||||
if (next.windowId === currentWindowId) return;
|
|
||||||
currentWindowId = next.windowId;
|
|
||||||
void refreshCodes();
|
void refreshCodes();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
<TotpRow
|
<TotpRow
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
live={totpCodes[cipher.id] || null}
|
||||||
onCopy={(value) => void copyToClipboard(value)}
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user