mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
360
Commits
v1.4.2
...
8128303d9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8128303d9b | ||
|
|
f6403b8cab | ||
|
|
a0128908e1 | ||
|
|
8e5d9e294b | ||
|
|
6ffdf05dc6 | ||
|
|
82d9f61163 | ||
|
|
f761fffd58 | ||
|
|
39c076b2e1 | ||
|
|
a50a503788 | ||
|
|
6cbc06f833 | ||
|
|
72d8ec9cba | ||
|
|
299eda597f | ||
|
|
19de8d6e57 | ||
|
|
63b642b251 | ||
|
|
e943357067 | ||
|
|
573451c52f | ||
|
|
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 | ||
|
|
5048cc0720 | ||
|
|
3f785febc8 | ||
|
|
907126d152 | ||
|
|
c1f57957c0 | ||
|
|
cd2ec8240b | ||
|
|
16bde22604 | ||
|
|
4900de0444 | ||
|
|
79ed7c9f85 | ||
|
|
9a21504f40 | ||
|
|
045b23fc47 | ||
|
|
42b765b113 | ||
|
|
f9fe53285f | ||
|
|
46ba8b9950 | ||
|
|
f096681a2b | ||
|
|
fe0c66c561 | ||
|
|
add921b3b3 | ||
|
|
f1b716fb31 | ||
|
|
8f2704fd41 | ||
|
|
7e0406f751 | ||
|
|
d5c2ab2b0f | ||
|
|
9e0908f43c | ||
|
|
7b3be2c819 | ||
|
|
a8183166ac | ||
|
|
f6169b7610 | ||
|
|
493f901ec1 | ||
|
|
b4dfb0409b | ||
|
|
a06cb0ed71 | ||
|
|
b0242265f4 | ||
|
|
b444c0f4b8 | ||
|
|
b1b25fe678 | ||
|
|
7cf2ab7c88 | ||
|
|
1918735520 | ||
|
|
c652cc1533 | ||
|
|
e9aef72df7 | ||
|
|
9adb24d4bb | ||
|
|
563570e3e0 | ||
|
|
3035a77579 | ||
|
|
28333f0e9b | ||
|
|
91320a4eba | ||
|
|
19b96a7aca | ||
|
|
18e0396c0a | ||
|
|
18d3490c4f | ||
|
|
615caf5946 | ||
|
|
1a10df4a18 | ||
|
|
d4749d3f82 | ||
|
|
5ed7c949c1 | ||
|
|
af70cab766 | ||
|
|
bfea5d0a1c | ||
|
|
cda654e1c3 | ||
|
|
1ee7b0f31b | ||
|
|
2d2cbea530 | ||
|
|
4f5d992f10 | ||
|
|
667afa305b | ||
|
|
85bd2fa4bf | ||
|
|
fd9707c396 | ||
|
|
192071e4a7 | ||
|
|
fcf7c80daa | ||
|
|
ed9251c014 | ||
|
|
a75955ca6d | ||
|
|
03f7fbf601 | ||
|
|
a63336764f | ||
|
|
f56d7f01ca | ||
|
|
8ff60aed24 | ||
|
|
749de4e2e1 | ||
|
|
ea9e238aa7 | ||
|
|
22d267f5bc | ||
|
|
18eefd1174 | ||
|
|
d468745841 | ||
|
|
970621c459 | ||
|
|
385a873e65 | ||
|
|
56185ecb69 | ||
|
|
04ebfc7021 | ||
|
|
c50247b8fe | ||
|
|
776408e9d0 | ||
|
|
e641da517d | ||
|
|
b7878ffe01 | ||
|
|
bbad9d60a7 | ||
|
|
ed58467766 | ||
|
|
2f911e66a6 | ||
|
|
d06e050162 | ||
|
|
d0dc31ce86 | ||
|
|
f64abaa75d | ||
|
|
7312086f92 | ||
|
|
3e4c104e1d | ||
|
|
17ceec45b1 | ||
|
|
2685741386 | ||
|
|
83a1fc2376 | ||
|
|
06431c4145 | ||
|
|
700910099b | ||
|
|
6b671450a8 | ||
|
|
c0df6d1c16 | ||
|
|
35f9512d94 | ||
|
|
9e39161fc7 | ||
|
|
7c58282e42 | ||
|
|
e0d81f2733 | ||
|
|
1d23b3fe5e | ||
|
|
a0d4d7a1ff | ||
|
|
2f1b61e883 | ||
|
|
4e62c90700 | ||
|
|
7afb496eb0 | ||
|
|
5809e3eebc | ||
|
|
2e9bbe6801 | ||
|
|
dc0eec7c54 | ||
|
|
a0605299f0 | ||
|
|
db68437a0b | ||
|
|
77d8411ea9 | ||
|
|
0c1ab3db48 | ||
|
|
6cc6e94b91 | ||
|
|
37ae493fa7 | ||
|
|
33f7c5d88a | ||
|
|
c6c8979772 | ||
|
|
a00279f47d | ||
|
|
669d7ef242 | ||
|
|
97d2117e15 | ||
|
|
429b747710 | ||
|
|
a06853835d | ||
|
|
c4ff063865 | ||
|
|
70b0a3a394 | ||
|
|
e7c07fda4e | ||
|
|
0a001bebcc | ||
|
|
246c73a3d3 | ||
|
|
3d95c959f7 | ||
|
|
e0737006c2 | ||
|
|
70dc9a76a9 | ||
|
|
ba38b77387 | ||
|
|
1b4d263d6e | ||
|
|
97a3aa691d | ||
|
|
0ab7c44981 | ||
|
|
75a6a593dc | ||
|
|
45f0387526 | ||
|
|
851c9c4080 | ||
|
|
a73f9a6d87 | ||
|
|
77a9faac88 | ||
|
|
0c00114cc8 | ||
|
|
9c5fbda374 | ||
|
|
85147e1569 | ||
|
|
29a846c562 | ||
|
|
3c5f43ecc2 | ||
|
|
68ded534a4 | ||
|
|
69b98f9e67 | ||
|
|
1b0386bf78 | ||
|
|
aa6f9210b4 | ||
|
|
3be6a16d90 | ||
|
|
fdb4cb91bf | ||
|
|
4b69f71ddb | ||
|
|
44020541e8 | ||
|
|
5869755c74 | ||
|
|
5b62d2142e | ||
|
|
575cf7ca79 | ||
|
|
bfd347a52c | ||
|
|
7ab836d0f3 | ||
|
|
d589b15123 | ||
|
|
f48f3d0c8e | ||
|
|
2f7e66ee69 | ||
|
|
0cffbcd1f8 | ||
|
|
64b4da4035 | ||
|
|
3d2285e7af | ||
|
|
62f0aedc27 | ||
|
|
193e0ca189 | ||
|
|
4a63c077f5 | ||
|
|
15ee922777 | ||
|
|
2ea0b2c14c | ||
|
|
4ec1926888 | ||
|
|
3995e01336 | ||
|
|
481536ba24 | ||
|
|
db8b9263a1 | ||
|
|
a1f7250e90 | ||
|
|
e4bc1b9bbe | ||
|
|
514889adfc | ||
|
|
fccc85c4bb | ||
|
|
acd59a7387 | ||
|
|
d40b0514fd | ||
|
|
033d44808f | ||
|
|
4246e179f1 | ||
|
|
fe8d9e0b7d | ||
|
|
1147c1e013 | ||
|
|
31ffd98166 | ||
|
|
7d7562d191 | ||
|
|
d6e5a1c40b | ||
|
|
77794e43ce | ||
|
|
b990f17a3e | ||
|
|
31b8ec6f7d | ||
|
|
ef47597be5 | ||
|
|
408874ac05 | ||
|
|
dabd2c923e | ||
|
|
08414d7cf2 | ||
|
|
38b33df719 | ||
|
|
7ebd12fa07 | ||
|
|
f7cbdaf730 | ||
|
|
6cae5cb218 | ||
|
|
d96ad9bb1c | ||
|
|
92d1f07998 | ||
|
|
a8432ab94b | ||
|
|
2230f75d8a | ||
|
|
a982a5a57b | ||
|
|
4d7ee2164a | ||
|
|
34d4851981 | ||
|
|
4827a4958e | ||
|
|
70463d3fc7 | ||
|
|
681705ee13 | ||
|
|
5bf7c79ada | ||
|
|
c516194d54 | ||
|
|
53231a4878 | ||
|
|
c9e7417825 | ||
|
|
76623d7201 | ||
|
|
90a7731351 | ||
|
|
f4adeb8ec9 | ||
|
|
bb0b82f838 | ||
|
|
be82c953d6 | ||
|
|
edd2ba2e44 | ||
|
|
0f6da7d147 | ||
|
|
1184cb8d9a | ||
|
|
882fa2e8c8 | ||
|
|
b6b7e46f79 | ||
|
|
144d3d9406 | ||
|
|
10707cf902 | ||
|
|
3bd4f6a9fe |
@@ -0,0 +1,5 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# JWT Secret for signing tokens (required)
|
|
||||||
# IMPORTANT: change this value before any real deployment.
|
|
||||||
# Generate one with: openssl rand -hex 32
|
|
||||||
# (Example only, 64 hex chars = 32 bytes)
|
|
||||||
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
custom:
|
||||||
|
- https://nodewarden.app/sponsor
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: "Bug Report"
|
||||||
|
description: "Report a reproducible bug / 反馈可复现问题"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||||
|
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||||
|
required: true
|
||||||
|
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version / 版本
|
||||||
|
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce_steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce / 复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. Start service with ...
|
||||||
|
2. Open ...
|
||||||
|
3. Click ...
|
||||||
|
4. Observe ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior / 预期行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior / 实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs and Screenshots / 日志与截图
|
||||||
|
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Any workaround, frequency, impact scope, etc."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Project Wiki/ 项目文档
|
||||||
|
url: https://nodewarden.app
|
||||||
|
about: |
|
||||||
|
Please check the documentation for common questions and troubleshooting steps.
|
||||||
|
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||||
|
- name: Project Discussions / 讨论区
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||||
|
about: |
|
||||||
|
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||||
|
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "Feature Request"
|
||||||
|
description: "Suggest an improvement / 功能建议"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Proposals with clear use-case and expected value are easier to evaluate.
|
||||||
|
说明清晰的使用场景和价值,有助于快速评估。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem Statement / 现存问题
|
||||||
|
description: "What is difficult or missing today?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution / 建议方案
|
||||||
|
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered / 备选方案
|
||||||
|
description: "Any alternatives or workarounds you've considered."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Expected Impact / 预期价值
|
||||||
|
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Scope (Optional) / 影响范围(可选)
|
||||||
|
placeholder: "frontend / backend / docs / deployment"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Mockups, references, related links, etc."
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What changed and why? -->
|
||||||
|
|
||||||
|
## Change Type
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
- [ ] Compatibility update
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactor
|
||||||
|
|
||||||
|
## Cross-File Checklist
|
||||||
|
|
||||||
|
- [ ] I read `CONTRIBUTING.md`.
|
||||||
|
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
|
||||||
|
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
|
||||||
|
- [ ] User-facing text changes, if any, updated all locale files.
|
||||||
|
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
|
||||||
|
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- [ ] `npx tsc -p tsconfig.json --noEmit`
|
||||||
|
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
|
||||||
|
- [ ] `npm run i18n:validate`
|
||||||
|
- [ ] `npm run build`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<!-- Anything reviewers should pay special attention to? -->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "05:00"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
npm-minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "tailwindcss"
|
||||||
|
update-types:
|
||||||
|
- "version-update:semver-major"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "05:10"
|
||||||
|
timezone: "Asia/Shanghai"
|
||||||
|
open-pull-requests-limit: 0
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security Report Generator (Node.js)
|
|
||||||
* Better, faster, and more maintainable than Bash.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class SecurityReport {
|
|
||||||
constructor() {
|
|
||||||
this.results = {
|
|
||||||
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
|
|
||||||
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
|
|
||||||
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
|
|
||||||
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
|
|
||||||
coverage: { actions: 0, js: 0, ts: 0 },
|
|
||||||
artifactUris: []
|
|
||||||
};
|
|
||||||
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
|
||||||
this.runId = process.env.GITHUB_RUN_ID || '0';
|
|
||||||
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
|
|
||||||
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
|
|
||||||
|
|
||||||
this.locales = {
|
|
||||||
zh: {
|
|
||||||
filename: 'security-report-cn.md',
|
|
||||||
switcher: '[English](security-report.md) | 中文',
|
|
||||||
title: '🛡️ 安全审计与透明度报告',
|
|
||||||
grade: '安全评级',
|
|
||||||
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
|
|
||||||
auditTime: '📅 审计时间',
|
|
||||||
runId: '📝 运行 ID',
|
|
||||||
env: '🛠️ 环境',
|
|
||||||
dashboard: '📉 实时安全仪表盘',
|
|
||||||
tool: '工具',
|
|
||||||
status: '状态',
|
|
||||||
findings: '发现项',
|
|
||||||
leaks: '泄露',
|
|
||||||
vulns: '漏洞',
|
|
||||||
alerts: '告警',
|
|
||||||
coverageTitle: '🔍 扫描覆盖范围',
|
|
||||||
module: '模块',
|
|
||||||
auditedFiles: '已审计文件',
|
|
||||||
coverage: '覆盖率',
|
|
||||||
detailedFindings: '🔍 详细发现项',
|
|
||||||
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
|
|
||||||
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
|
|
||||||
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
|
|
||||||
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
|
|
||||||
snykTitle: '📦 第三方依赖',
|
|
||||||
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
|
|
||||||
package: '软件包',
|
|
||||||
severity: '严重程度',
|
|
||||||
description: '描述',
|
|
||||||
fixPlan: '修复方案',
|
|
||||||
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
|
|
||||||
codeqlSummary: '#### 摘要',
|
|
||||||
rulesChecked: '已检查规则',
|
|
||||||
totalAlerts: '告警总数',
|
|
||||||
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
|
|
||||||
ruleId: '规则 ID',
|
|
||||||
level: '级别',
|
|
||||||
location: '位置',
|
|
||||||
auditedList: '📂 已审计文件列表',
|
|
||||||
guideTitle: '⚠️ 操作指南',
|
|
||||||
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
|
|
||||||
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
|
||||||
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
|
||||||
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
|
||||||
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
|
||||||
auditedIcon: '✅ **已审计**',
|
|
||||||
noFiles: '未检索到文件。',
|
|
||||||
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
|
||||||
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
|
|
||||||
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
filename: 'security-report.md',
|
|
||||||
switcher: 'English | [中文](security-report-cn.md)',
|
|
||||||
title: '🛡️ Security Audit & Transparency Report',
|
|
||||||
grade: 'Security Grade',
|
|
||||||
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
|
|
||||||
auditTime: '📅 Audit Time',
|
|
||||||
runId: '📝 Run ID',
|
|
||||||
env: '🛠️ Environment',
|
|
||||||
dashboard: '📉 Real-time Security Dashboard',
|
|
||||||
tool: 'Tool',
|
|
||||||
status: 'Status',
|
|
||||||
findings: 'Findings',
|
|
||||||
leaks: 'Leaks',
|
|
||||||
vulns: 'Vulns',
|
|
||||||
alerts: 'Alerts',
|
|
||||||
coverageTitle: '🔍 Scan Coverage',
|
|
||||||
module: 'Module',
|
|
||||||
auditedFiles: 'Audited Files',
|
|
||||||
coverage: 'Coverage',
|
|
||||||
detailedFindings: '🔍 Detailed Findings',
|
|
||||||
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
|
|
||||||
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
|
|
||||||
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
|
|
||||||
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
|
|
||||||
snykTitle: '📦 Third-party Dependencies',
|
|
||||||
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
|
|
||||||
package: 'Package',
|
|
||||||
severity: 'Severity',
|
|
||||||
description: 'Description',
|
|
||||||
fixPlan: 'Fix Plan',
|
|
||||||
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
|
|
||||||
codeqlSummary: '#### Summary',
|
|
||||||
rulesChecked: 'Rules Checked',
|
|
||||||
totalAlerts: 'Total Alerts',
|
|
||||||
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
|
|
||||||
ruleId: 'Rule ID',
|
|
||||||
level: 'Level',
|
|
||||||
location: 'Location',
|
|
||||||
auditedList: '📂 Audited File List',
|
|
||||||
guideTitle: '⚠️ Action Guide',
|
|
||||||
guideDesc: 'If you see a **FAIL** status or serious code issues:',
|
|
||||||
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
|
||||||
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
|
||||||
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
|
||||||
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
|
||||||
auditedIcon: '✅ **Audited**',
|
|
||||||
noFiles: 'No files found.',
|
|
||||||
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
|
||||||
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
|
|
||||||
trivySafe: '✅ **SAFE**: No container configuration defects found.'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Data Parsers ---
|
|
||||||
|
|
||||||
async parseCodeQL() {
|
|
||||||
const sarifPath = 'sarif-results';
|
|
||||||
if (!fs.existsSync(sarifPath)) return;
|
|
||||||
|
|
||||||
const files = this.globFiles(sarifPath, '.sarif');
|
|
||||||
let totalAlerts = 0;
|
|
||||||
let rulesSet = new Set();
|
|
||||||
let findings = [];
|
|
||||||
let artifactUris = new Set();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
||||||
for (const run of data.runs || []) {
|
|
||||||
// Collect Rules
|
|
||||||
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
|
|
||||||
(run.tool.extensions || []).forEach(ext => {
|
|
||||||
(ext.rules || []).forEach(r => rulesSet.add(r.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect Results
|
|
||||||
for (const res of run.results || []) {
|
|
||||||
totalAlerts++;
|
|
||||||
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
|
|
||||||
findings.push({
|
|
||||||
id: res.ruleId,
|
|
||||||
level: res.level || 'warning',
|
|
||||||
path: loc.artifactLocation?.uri || 'Global',
|
|
||||||
line: loc.region?.startLine || '-',
|
|
||||||
message: res.message?.text || 'No description'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track Coverage (Deduplicated)
|
|
||||||
(run.artifacts || []).forEach(art => {
|
|
||||||
const uri = art.location?.uri || '';
|
|
||||||
if (uri) artifactUris.add(uri);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.artifactUris = Array.from(artifactUris).sort();
|
|
||||||
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
|
|
||||||
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
|
|
||||||
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
|
|
||||||
|
|
||||||
this.results.codeql.alertCount = totalAlerts;
|
|
||||||
this.results.codeql.rulesCount = rulesSet.size;
|
|
||||||
this.results.codeql.findings = findings;
|
|
||||||
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseSnyk() {
|
|
||||||
const jsonPath = 'snyk_result.json';
|
|
||||||
if (!fs.existsSync(jsonPath)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
||||||
const projects = Array.isArray(data) ? data : [data];
|
|
||||||
let vulnTotal = 0;
|
|
||||||
let findings = [];
|
|
||||||
|
|
||||||
for (const proj of projects) {
|
|
||||||
const vulns = proj.vulnerabilities || [];
|
|
||||||
vulnTotal += vulns.length;
|
|
||||||
vulns.forEach(v => {
|
|
||||||
findings.push({
|
|
||||||
pkg: `${v.packageName}@${v.version}`,
|
|
||||||
severity: v.severity,
|
|
||||||
title: v.title,
|
|
||||||
url: v.url,
|
|
||||||
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.snyk.vulnCount = vulnTotal;
|
|
||||||
this.results.snyk.findings = findings;
|
|
||||||
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing Snyk JSON:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseGitleaks() {
|
|
||||||
const files = this.globFiles('.', 'results.sarif');
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
|
||||||
let leaks = 0;
|
|
||||||
let findings = [];
|
|
||||||
for (const run of data.runs || []) {
|
|
||||||
for (const res of run.results || []) {
|
|
||||||
leaks++;
|
|
||||||
findings.push({
|
|
||||||
id: res.ruleId,
|
|
||||||
message: res.message.text,
|
|
||||||
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.results.gitleaks.leaksCount = leaks;
|
|
||||||
this.results.gitleaks.findings = findings;
|
|
||||||
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing Gitleaks SARIF:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async parseTrivy() {
|
|
||||||
const jsonPath = 'trivy_result.json';
|
|
||||||
if (!fs.existsSync(jsonPath)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
||||||
let misconfigs = 0;
|
|
||||||
let findings = [];
|
|
||||||
|
|
||||||
(data.Results || []).forEach(res => {
|
|
||||||
(res.Misconfigurations || []).forEach(m => {
|
|
||||||
misconfigs++;
|
|
||||||
findings.push({
|
|
||||||
id: m.ID,
|
|
||||||
severity: m.Severity,
|
|
||||||
title: m.Title,
|
|
||||||
message: m.Message,
|
|
||||||
status: m.Status,
|
|
||||||
target: res.Target
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.results.trivy.misconfigCount = misconfigs;
|
|
||||||
this.results.trivy.findings = findings;
|
|
||||||
if (misconfigs > 0) this.results.trivy.status = 'WARN';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing Trivy JSON:', e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
generateTable(type, t) {
|
|
||||||
let files = [];
|
|
||||||
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
|
|
||||||
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
|
|
||||||
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
|
|
||||||
|
|
||||||
if (files.length === 0) return `> ${t.noFiles}\n`;
|
|
||||||
|
|
||||||
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
|
|
||||||
files.forEach(f => {
|
|
||||||
const filename = path.basename(f);
|
|
||||||
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
|
|
||||||
});
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Renderers ---
|
|
||||||
|
|
||||||
generateMarkdown(localeKey) {
|
|
||||||
const { codeql, snyk, gitleaks, coverage } = this.results;
|
|
||||||
const t = this.locales[localeKey];
|
|
||||||
|
|
||||||
// Calculate Grade
|
|
||||||
let grade = 'A+';
|
|
||||||
let gradeColor = 'success';
|
|
||||||
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
|
|
||||||
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
|
|
||||||
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
|
|
||||||
|
|
||||||
const badge = (label, value, color) => `}-${value}-${color}?style=for-the-badge)`;
|
|
||||||
|
|
||||||
let md = `# ${t.title}\n\n`;
|
|
||||||
md += `${t.switcher}\n\n`;
|
|
||||||
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
|
|
||||||
md += `${t.important}\n\n`;
|
|
||||||
|
|
||||||
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
|
|
||||||
md += `| :--- | :--- | :--- |\n`;
|
|
||||||
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
|
|
||||||
|
|
||||||
md += `---\n\n## ${t.dashboard}\n\n`;
|
|
||||||
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
|
|
||||||
md += `| :--- | :--- | :--- |\n`;
|
|
||||||
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
|
|
||||||
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
|
|
||||||
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
|
|
||||||
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
|
|
||||||
|
|
||||||
md += `---\n\n## ${t.coverageTitle}\n\n`;
|
|
||||||
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
|
|
||||||
md += `| :--- | :---: | :---: |\n`;
|
|
||||||
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
|
|
||||||
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
|
|
||||||
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
|
|
||||||
|
|
||||||
md += `---\n\n## ${t.detailedFindings}\n\n`;
|
|
||||||
|
|
||||||
// Gitleaks Section
|
|
||||||
md += `### ${t.gitleaksTitle}\n`;
|
|
||||||
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
|
|
||||||
if (gitleaks.findings.length > 0) {
|
|
||||||
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
|
|
||||||
md += `| :--- | :--- | :--- |\n`;
|
|
||||||
gitleaks.findings.forEach(f => {
|
|
||||||
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
md += `${t.gitleaksSafe}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trivy Section
|
|
||||||
md += `\n### ${t.trivyTitle}\n`;
|
|
||||||
md += `${t.trivyDesc}\n\n`;
|
|
||||||
if (this.results.trivy.findings.length > 0) {
|
|
||||||
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
|
|
||||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
|
||||||
this.results.trivy.findings.forEach(f => {
|
|
||||||
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
|
|
||||||
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
md += `${t.trivySafe}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snyk Section
|
|
||||||
md += `\n### ${t.snykTitle}\n`;
|
|
||||||
if (snyk.findings.length > 0) {
|
|
||||||
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
|
|
||||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
|
||||||
snyk.findings.forEach(f => {
|
|
||||||
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
|
|
||||||
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
md += `${t.snykSafe}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CodeQL Section
|
|
||||||
md += `\n### ${t.codeqlTitle}\n`;
|
|
||||||
if (codeql.findings.length > 0) {
|
|
||||||
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
|
|
||||||
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
|
|
||||||
md += `| :--- | :---: | :--- | :--- |\n`;
|
|
||||||
codeql.findings.forEach(f => {
|
|
||||||
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
|
|
||||||
const prefix = f.id.split('/')[0];
|
|
||||||
const langMap = {
|
|
||||||
'js': 'javascript',
|
|
||||||
'actions': 'github-actions',
|
|
||||||
'cpp': 'cpp',
|
|
||||||
'cs': 'csharp',
|
|
||||||
'go': 'go',
|
|
||||||
'java': 'java',
|
|
||||||
'py': 'python',
|
|
||||||
'rb': 'ruby',
|
|
||||||
'swift': 'swift'
|
|
||||||
};
|
|
||||||
const langPath = langMap[prefix] || 'javascript';
|
|
||||||
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
md += `${t.codeqlSafe}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audited Files List
|
|
||||||
md += `\n### ${t.auditedList}\n`;
|
|
||||||
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
|
|
||||||
md += this.generateTable('actions', t);
|
|
||||||
md += `\n</details>\n\n`;
|
|
||||||
|
|
||||||
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
|
|
||||||
md += this.generateTable('js', t);
|
|
||||||
md += `\n</details>\n\n`;
|
|
||||||
|
|
||||||
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
|
|
||||||
md += this.generateTable('ts', t);
|
|
||||||
md += `\n</details>\n\n`;
|
|
||||||
|
|
||||||
// Action Guide
|
|
||||||
md += `--- \n\n## ${t.guideTitle}\n\n`;
|
|
||||||
md += `${t.guideDesc}\n`;
|
|
||||||
md += `${t.guideStep1}\n`;
|
|
||||||
md += `${t.guideStep2}\n`;
|
|
||||||
md += `${t.guideStep3}\n\n`;
|
|
||||||
|
|
||||||
md += `--- \n\n${t.footer}`;
|
|
||||||
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
getBadge(status) {
|
|
||||||
if (status === 'PASS') return '';
|
|
||||||
if (status === 'WARN' || status === 'INFO') return '';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
globFiles(dir, ext) {
|
|
||||||
let results = [];
|
|
||||||
const list = fs.readdirSync(dir);
|
|
||||||
for (const file of list) {
|
|
||||||
const fullPath = path.join(dir, file);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
if (stat && stat.isDirectory()) {
|
|
||||||
results = results.concat(this.globFiles(fullPath, ext));
|
|
||||||
} else if (file.endsWith(ext)) {
|
|
||||||
results.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async run() {
|
|
||||||
console.log('--- Security Report Generation Started ---');
|
|
||||||
await this.parseCodeQL();
|
|
||||||
await this.parseSnyk();
|
|
||||||
await this.parseGitleaks();
|
|
||||||
await this.parseTrivy();
|
|
||||||
|
|
||||||
for (const localeKey of Object.keys(this.locales)) {
|
|
||||||
const locale = this.locales[localeKey];
|
|
||||||
const markdown = this.generateMarkdown(localeKey);
|
|
||||||
fs.writeFileSync(locale.filename, markdown);
|
|
||||||
console.log(`Report generated successfully at ${locale.filename}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new SecurityReport().run().catch(err => {
|
|
||||||
console.error('Report generation failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: "CodeQL Advanced"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
security-events: write
|
||||||
|
packages: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: CodeQL Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: actions
|
||||||
|
build-mode: none
|
||||||
|
- language: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
name: "Extra Security Scan"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Gitleaks Secret Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run Gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITLEAKS_ENABLE_SUMMARY: "true"
|
||||||
|
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
|
||||||
|
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
|
||||||
|
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||||
|
|
||||||
|
osv:
|
||||||
|
name: OSV Dependency Scan
|
||||||
|
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--recursive
|
||||||
|
./
|
||||||
|
upload-sarif: true
|
||||||
|
fail-on-vuln: true
|
||||||
|
|
||||||
|
pnpm-audit:
|
||||||
|
name: pnpm audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Run pnpm audit
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ ! -f pnpm-lock.yaml ]; then
|
||||||
|
echo "pnpm-lock.yaml not found, skip pnpm audit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@10 --activate
|
||||||
|
pnpm audit --audit-level=high
|
||||||
|
|
||||||
|
semgrep:
|
||||||
|
name: Semgrep CE Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run Semgrep CE
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "${PWD}:/src" \
|
||||||
|
-w /src \
|
||||||
|
semgrep/semgrep:latest \
|
||||||
|
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
|
||||||
|
|
||||||
|
if [ ! -f semgrep.sarif ]; then
|
||||||
|
cat > semgrep.sarif <<'EOF'
|
||||||
|
{
|
||||||
|
"version": "2.1.0",
|
||||||
|
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"tool": {
|
||||||
|
"driver": {
|
||||||
|
"name": "Semgrep",
|
||||||
|
"informationUri": "https://semgrep.dev",
|
||||||
|
"rules": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Semgrep SARIF
|
||||||
|
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||||
|
with:
|
||||||
|
sarif_file: semgrep.sarif
|
||||||
|
category: semgrep
|
||||||
|
|
||||||
|
actionlint:
|
||||||
|
name: GitHub Actions Syntax Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run actionlint
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "${PWD}:/repo" \
|
||||||
|
-w /repo \
|
||||||
|
rhysd/actionlint:latest
|
||||||
|
|
||||||
|
zizmor:
|
||||||
|
name: GitHub Actions Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run zizmor
|
||||||
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
|
||||||
|
with:
|
||||||
|
persona: auditor
|
||||||
|
min-severity: medium
|
||||||
|
min-confidence: medium
|
||||||
|
|
||||||
|
scorecard:
|
||||||
|
name: OpenSSF Scorecard
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run OpenSSF Scorecard
|
||||||
|
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
|
||||||
|
with:
|
||||||
|
results_file: scorecard.sarif
|
||||||
|
results_format: sarif
|
||||||
|
publish_results: false
|
||||||
|
|
||||||
|
- name: Upload Scorecard SARIF
|
||||||
|
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
|
||||||
|
with:
|
||||||
|
sarif_file: scorecard.sarif
|
||||||
|
category: openssf-scorecard
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
name: Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
actions: read
|
|
||||||
env:
|
|
||||||
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
if: env.ACT != 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
uses: github/codeql-action/init@v4
|
|
||||||
with:
|
|
||||||
languages: javascript-typescript, actions
|
|
||||||
build-mode: none
|
|
||||||
queries: security-extended,security-and-quality
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
if: env.ACT != 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
uses: github/codeql-action/analyze@v4
|
|
||||||
with:
|
|
||||||
upload: true
|
|
||||||
output: sarif-results
|
|
||||||
|
|
||||||
- name: Install Gitleaks
|
|
||||||
if: env.ACT != 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
GITLEAKS_VERSION="8.28.0"
|
|
||||||
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
|
||||||
tar -xzf gitleaks.tar.gz gitleaks
|
|
||||||
chmod +x gitleaks
|
|
||||||
sudo mv gitleaks /usr/local/bin/gitleaks
|
|
||||||
|
|
||||||
- name: Secret Detection
|
|
||||||
if: env.ACT != 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
|
|
||||||
|
|
||||||
- name: Install Project Dependencies
|
|
||||||
if: env.SECURITY_SNYK_TOKEN != ''
|
|
||||||
env:
|
|
||||||
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
|
|
||||||
run: |
|
|
||||||
echo "Preparing dependency lock files for security scanning..."
|
|
||||||
if [ -z "$SECURITY_PACKAGE" ]; then
|
|
||||||
echo "SECURITY_PACKAGE is empty, installing in root..."
|
|
||||||
npm install --package-lock-only
|
|
||||||
else
|
|
||||||
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
|
|
||||||
# Split by comma and install
|
|
||||||
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
|
|
||||||
for pkg in "${PACKAGES[@]}"; do
|
|
||||||
if [ -d "$pkg" ]; then
|
|
||||||
echo "Installing in "$pkg"..."
|
|
||||||
npm install --prefix "$pkg" --package-lock-only
|
|
||||||
else
|
|
||||||
echo "Warning: Directory $pkg not found, skipping."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Dependency Scan
|
|
||||||
id: snyk
|
|
||||||
if: env.SECURITY_SNYK_TOKEN != ''
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
npm install -g snyk
|
|
||||||
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
|
|
||||||
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
|
|
||||||
env:
|
|
||||||
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
|
||||||
|
|
||||||
- name: Check for Dockerfile
|
|
||||||
id: check_docker
|
|
||||||
run: |
|
|
||||||
if [ -f "Dockerfile" ]; then
|
|
||||||
echo "exists=true" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "exists=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Container Security Scan (Trivy)
|
|
||||||
if: steps.check_docker.outputs.exists == 'true'
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
VERSION="0.56.1"
|
|
||||||
echo "Installing Trivy $VERSION..."
|
|
||||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
|
|
||||||
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
|
|
||||||
|
|
||||||
- name: Generate Security Report
|
|
||||||
run: |
|
|
||||||
# Gitleaks typically produces results.sarif if configured or by default in some versions
|
|
||||||
# We'll ensure it exists for our reporter
|
|
||||||
node .github/scripts/security.cjs
|
|
||||||
|
|
||||||
# Also append to step summary for immediate visibility in GHA UI
|
|
||||||
cat security-report.md >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
|
|
||||||
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
- name: Upload Gitleaks Results to GitHub Security
|
|
||||||
uses: github/codeql-action/upload-sarif@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
sarif_file: results.sarif
|
|
||||||
category: gitleaks
|
|
||||||
|
|
||||||
- name: Upload Security Report Artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: security-report
|
|
||||||
if-no-files-found: ignore
|
|
||||||
path: |
|
|
||||||
security-report.md
|
|
||||||
security-report-cn.md
|
|
||||||
snyk_result.txt
|
|
||||||
snyk_result.json
|
|
||||||
trivy_result.json
|
|
||||||
results.sarif
|
|
||||||
sarif-results/*.sarif
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Sync Bitwarden global domains
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bitwarden_ref:
|
||||||
|
description: "bitwarden/server ref to sync"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-global-domains:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Sync generated Bitwarden domains
|
||||||
|
env:
|
||||||
|
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
|
||||||
|
run: |
|
||||||
|
case "$BITWARDEN_REF" in
|
||||||
|
"" | *[!A-Za-z0-9._/-]* )
|
||||||
|
echo "Invalid bitwarden_ref"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
npm run domains:sync -- --ref "$BITWARDEN_REF"
|
||||||
|
|
||||||
|
- name: Verify custom domains were not touched
|
||||||
|
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||||
|
with:
|
||||||
|
branch: chore/sync-bitwarden-global-domains
|
||||||
|
delete-branch: true
|
||||||
|
title: "chore: sync Bitwarden global domain rules"
|
||||||
|
commit-message: "chore: sync Bitwarden global domain rules"
|
||||||
|
body: |
|
||||||
|
Automated sync from bitwarden/server.
|
||||||
|
|
||||||
|
This PR only updates:
|
||||||
|
- `src/static/global_domains.bitwarden.json`
|
||||||
|
- `src/static/global_domains.bitwarden.meta.json`
|
||||||
|
|
||||||
|
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||||
|
add-paths: |
|
||||||
|
src/static/global_domains.bitwarden.json
|
||||||
|
src/static/global_domains.bitwarden.meta.json
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: Sync upstream
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 3 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
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: Sync main from upstream
|
|
||||||
run: |
|
|
||||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
|
||||||
git fetch upstream
|
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
|
|
||||||
- name: Push synced main
|
|
||||||
run: |
|
|
||||||
git push origin main
|
|
||||||
+25
-1
@@ -18,6 +18,7 @@ build/
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
docs/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -26,7 +27,7 @@ Thumbs.db
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
.vite-tailwind.err
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
@@ -40,3 +41,26 @@ npm-debug.log*
|
|||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.tmp-bitwarden-clients/
|
||||||
|
|
||||||
|
nodewarden-wiki/
|
||||||
|
wiki/
|
||||||
|
AGENTS.md
|
||||||
|
settings.json
|
||||||
|
.claude/
|
||||||
|
NodeWarden-compat/
|
||||||
|
.codex-upstream/
|
||||||
|
.codex-upstream/bitwarden-server/
|
||||||
|
.codex-upstream/bitwarden-clients/
|
||||||
|
.codex-upstream/bitwarden-web/
|
||||||
|
.codex-upstream/bitwarden-browser/
|
||||||
|
|
||||||
|
.reasonix/
|
||||||
|
.upstream/
|
||||||
|
|
||||||
|
# Compatibility analysis documents
|
||||||
|
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||||
|
security-audits/
|
||||||
|
.mcp.json
|
||||||
|
opencode.jsonc
|
||||||
|
.cursor/
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributing to NodeWarden
|
||||||
|
|
||||||
|
Thanks for taking the time to improve NodeWarden.
|
||||||
|
|
||||||
|
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
|
||||||
|
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
|
||||||
|
Small changes can affect official clients, backups, migrations, or locale files,
|
||||||
|
so please keep changes focused and check the related parts of the project.
|
||||||
|
|
||||||
|
## Before Opening an Issue
|
||||||
|
|
||||||
|
For bug reports, include enough detail for someone else to reproduce the problem:
|
||||||
|
|
||||||
|
- The client or browser you used.
|
||||||
|
- The page, API route, or action that failed.
|
||||||
|
- Screenshots, logs, or the exact error message.
|
||||||
|
- Whether the problem happened after sync, import, export, restore, upgrade, or
|
||||||
|
a fresh deployment.
|
||||||
|
|
||||||
|
Please do not report NodeWarden-specific problems to the official Bitwarden
|
||||||
|
team. This project is independent from Bitwarden.
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
Keep pull requests small enough to review. A good PR should explain:
|
||||||
|
|
||||||
|
- What changed and why.
|
||||||
|
- What user-facing behavior changed.
|
||||||
|
- Which related areas were checked.
|
||||||
|
- Which commands were run before submitting.
|
||||||
|
|
||||||
|
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
|
||||||
|
needed before the real fix, mention that clearly in the PR.
|
||||||
|
|
||||||
|
## Areas That Need Extra Care
|
||||||
|
|
||||||
|
Some parts of the codebase are deliberately connected. When changing one of
|
||||||
|
these areas, check the related files before calling the work complete.
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
|
||||||
|
lives in `migrations/0001_init.sql`.
|
||||||
|
|
||||||
|
If you add or change a table, column, or index:
|
||||||
|
|
||||||
|
- Update both schema files.
|
||||||
|
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
|
||||||
|
- Decide whether the data should be included in instance backup.
|
||||||
|
|
||||||
|
### Backup And Restore
|
||||||
|
|
||||||
|
Backup export and restore are whitelist-based. This protects old backups from
|
||||||
|
breaking when fields are removed and prevents transient or secret runtime data
|
||||||
|
from being exported by accident.
|
||||||
|
|
||||||
|
When adding persistent data, check:
|
||||||
|
|
||||||
|
- `src/services/backup-archive.ts`
|
||||||
|
- `src/services/backup-import.ts`
|
||||||
|
- `webapp/src/lib/api/backup.ts`
|
||||||
|
|
||||||
|
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
|
||||||
|
retired sensitive fields such as `users.api_key`.
|
||||||
|
|
||||||
|
### Secrets And Provider Settings
|
||||||
|
|
||||||
|
Provider credentials must not be stored or exported as plain config JSON. Follow
|
||||||
|
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
|
||||||
|
document a replacement design before changing it.
|
||||||
|
|
||||||
|
### Bitwarden Client Compatibility
|
||||||
|
|
||||||
|
Official Bitwarden clients may send or expect fields that are not used directly
|
||||||
|
by the web vault. Cipher and sync changes should preserve unknown client fields
|
||||||
|
unless they are known-invalid or server-owned.
|
||||||
|
|
||||||
|
Check these files when changing vault item shape or sync behavior:
|
||||||
|
|
||||||
|
- `src/handlers/ciphers.ts`
|
||||||
|
- `src/handlers/sync.ts`
|
||||||
|
- `src/services/storage-cipher-repo.ts`
|
||||||
|
|
||||||
|
### Domain Rules
|
||||||
|
|
||||||
|
Equivalent-domain settings store both client/UI rule state and derived active
|
||||||
|
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
|
||||||
|
`excluded_global_equivalent_domains` as duplicates without a migration and
|
||||||
|
compatibility plan.
|
||||||
|
|
||||||
|
### Accounts And Passwords
|
||||||
|
|
||||||
|
`users.master_password_hash` is for server-side login verification. It is not the
|
||||||
|
vault decryption key. Password changes, key material, `securityStamp`, and
|
||||||
|
refresh-token revocation must stay aligned.
|
||||||
|
|
||||||
|
Password hints are reminders, not recovery secrets. They must never contain the
|
||||||
|
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||||
|
vault.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||||
|
text, keep every locale in sync and run the validation script.
|
||||||
|
|
||||||
|
For new locales, update:
|
||||||
|
|
||||||
|
- `webapp/src/lib/i18n.ts`
|
||||||
|
- `webapp/src/lib/i18n/locales/*`
|
||||||
|
- `scripts/i18n-utils.cjs`
|
||||||
|
|
||||||
|
## Recommended Checks
|
||||||
|
|
||||||
|
For most backend or shared changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx tsc -p tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For webapp text or locale changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n:validate
|
||||||
|
npx tsc -p webapp/tsconfig.json --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For documentation-only changes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(0 30) scale(0.276)">
|
||||||
|
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
|
||||||
|
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
|
||||||
|
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
|
||||||
|
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
|
||||||
|
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
|
||||||
|
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
|
||||||
|
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
|
||||||
|
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
|
||||||
|
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
|
||||||
|
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
|
||||||
|
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
|
||||||
|
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -1,74 +1,104 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
<p align="center">
|
||||||
[](./LICENSE)
|
<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>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
<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>
|
||||||
|
|
||||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
</p>
|
||||||
|
|
||||||
English: [`README_EN.md`](./README_EN.md)
|
<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_ZH.md">中文</a> |
|
||||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||||
|
<a href="https://nodewarden.app">Official wiki</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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## Feature comparison with the official Bitwarden server
|
||||||
|
|
||||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||||
|
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||||
|
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||||
|
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||||
|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||||
|
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已测试客户端
|
## Tested clients
|
||||||
|
|
||||||
- ✅ Windows 桌面端
|
- ✅ Windows desktop
|
||||||
- ✅ 手机 App
|
- ✅ Mobile app
|
||||||
- ✅ 浏览器扩展
|
- ✅ Browser extension
|
||||||
- ✅ Linux 桌面端
|
- ✅ Linux desktop
|
||||||
- ⚠️ macOS 桌面端尚未完整验证
|
- ⚠️ macOS desktop not fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 网页部署
|
## Visual quick deploy
|
||||||
|
|
||||||
|
1. Fork the NodeWarden repository to your GitHub account
|
||||||
|
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
|
3. Choose **Continue with GitHub** and select your fork
|
||||||
|
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||||
|
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||||
|
5. After deployment finishes, open the generated Workers URL
|
||||||
|
|
||||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
- 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).
|
||||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- To hide the Web Vault, add a text variable named `HIDE_WEB_VAULT` with the value `1` under **Workers settings → Variables and Secrets**. While enabled, server-hosted frontend pages and static assets return `404 Not Found`, while the login, sync, attachment, icon, notification, and other server endpoints used by Bitwarden clients remain available; an already installed or cached PWA can continue using its local frontend. Delete the variable (or change it to anything other than `1`) to restore the server-hosted Web Vault.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
|
||||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 同步方法(更新仓库):
|
> Default R2 vs optional KV:
|
||||||
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||||
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
> |---|---|---|---|
|
||||||
|
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
|
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## How to update
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -77,68 +107,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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 云端备份说明
|
|
||||||
|
|
||||||
- 远程备份支持 **WebDAV** 与 **E3**
|
## License
|
||||||
- 勾选“包含附件”后:
|
|
||||||
- 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>
|
||||||
|
|||||||
-141
@@ -1,141 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
|
||||||
[](./LICENSE)
|
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
|
||||||
|
|
||||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
|
||||||
|
|
||||||
English: [`README.md`](./README.md)
|
|
||||||
|
|
||||||
> **Disclaimer**
|
|
||||||
> This project is for learning and communication 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 Official Bitwarden Server
|
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
|
||||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
|
||||||
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
|
||||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
|
||||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
|
||||||
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
|
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tested Clients
|
|
||||||
|
|
||||||
- ✅ Windows desktop client
|
|
||||||
- ✅ Mobile app
|
|
||||||
- ✅ Browser extension
|
|
||||||
- ✅ Linux desktop client
|
|
||||||
- ⚠️ macOS desktop client not fully verified
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Deploy
|
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, please 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`) -> `Next` -> deploy.
|
|
||||||
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
|
|
||||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> How to keep your fork updated:
|
|
||||||
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
|
||||||
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
|
||||||
|
|
||||||
## CLI Deploy
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
|
|
||||||
npm install
|
|
||||||
npx wrangler login
|
|
||||||
|
|
||||||
# Default: R2 mode
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# Optional: KV mode
|
|
||||||
npm run deploy:kv
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
npm run dev
|
|
||||||
npm run dev:kv
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cloud Backup Notes
|
|
||||||
|
|
||||||
- Remote backup supports **WebDAV** and **E3**
|
|
||||||
- When `Include attachments` is enabled:
|
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
|
||||||
- real attachment files are stored separately under `attachments/`
|
|
||||||
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
|
||||||
- During remote restore:
|
|
||||||
- required attachment files are loaded from `attachments/`
|
|
||||||
- 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 visible 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)
|
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
<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 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||||
|
|
||||||
|
- 如需隐藏 Web Vault,在 Workers 的“设置 → 变量和机密”中添加文本变量 `HIDE_WEB_VAULT`,值设为 `1`。启用后,服务器上的前端页面和静态资源统一返回 `404 Not Found`,Bitwarden 客户端所需的登录、同步、附件、图标、通知等服务端接口仍可使用;已经安装或缓存的 PWA 可以继续使用本地前端。删除该变量(或将值改为非 `1`)即可恢复服务器上的 Web Vault。
|
||||||
|
|
||||||
|
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 默认R2与可选KV的区别:
|
||||||
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## CLI 部署
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 默认:R2 模式
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# 可选:KV 模式
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<!--
|
||||||
|
Release note writing rules:
|
||||||
|
1. Always add the newest release at the top of this file; do not delete older release notes.
|
||||||
|
2. Move previous releases down unchanged, then write the new release above them.
|
||||||
|
3. Use this exact release structure:
|
||||||
|
- One H1 title: "# vX.Y.Z - Short English Summary".
|
||||||
|
- English section first with "### Added", "### Improved", and "### Fixed".
|
||||||
|
- Then a horizontal rule "---".
|
||||||
|
- Chinese section second with "### 新增", "### 改进", and "### 修复".
|
||||||
|
4. Use numbered items under each group. Each item must be:
|
||||||
|
- "1. **Short feature/fix title.** One concise paragraph explaining what changed and why it matters."
|
||||||
|
- No one-line commit dumps, no raw changelog lists, no vague marketing copy.
|
||||||
|
- Merge related commits into one readable item instead of listing every commit separately.
|
||||||
|
5. Keep the tone user-friendly and concrete:
|
||||||
|
- Explain behavior, compatibility, UI impact, migration impact, or bug impact in plain language.
|
||||||
|
- Be concise but specific; one item is usually 1-3 sentences.
|
||||||
|
- Avoid internal-only implementation details unless they explain a user-visible change.
|
||||||
|
6. Add GitHub commit links at the end of every numbered item:
|
||||||
|
- English: "Commit: [abc1234](...)." or "Commits: [abc1234](...), [def5678](...)."
|
||||||
|
- Chinese: "提交:[abc1234](...)。" or "提交:[abc1234](...)、[def5678](...)。"
|
||||||
|
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **Web Vault visibility control.** Cloudflare Workers deployments can now set `HIDE_WEB_VAULT=1` to return `404 Not Found` for server-hosted Web Vault pages and static assets while keeping Bitwarden login, sync, attachment, icon, notification, and compatibility endpoints available. Removing the variable restores the Web Vault, and already installed or cached PWAs can continue using their local frontend. Commit: [d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **Simpler desktop and mobile navigation.** The Web Vault now uses a consistent grouped sidebar with persistent expanded sections, clearer separation between tools, settings, and system management, and matching mobile settings navigation. This removes the previous layout-mode picker and makes frequently used destinations easier to find. Commit: [e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d).
|
||||||
|
|
||||||
|
1. **Safer YubiKey validation credential management.** Yubico validation credentials now use centralized, concurrency-safe initialization; only active administrators can view or replace configured credentials, and credential initialization and reconfiguration are recorded in the security audit log. Regular users can still enroll YubiKeys without gaining access to server-wide credentials. Commit: [573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437).
|
||||||
|
|
||||||
|
1. **Bitwarden-compatible personal API keys and safer backups.** Personal API keys can now be viewed after master-password verification and rotated without revoking unrelated sessions. Legacy hashed keys continue to authenticate until the user explicitly rotates them, while new backups exclude personal API keys and runtime authentication or device-trust state; older archives remain importable, but that runtime state is intentionally ignored during restore. Commit: [299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **Unexpected logout during temporary refresh failures.** Refresh sessions now use client-aware sliding lifetimes with an absolute upper bound, and transient network or service failures no longer turn a locked Web Vault into a forced full login. The Web Vault preserves the locked session, shows a retry path, and keeps official desktop, mobile, browser-extension, and web-cookie flows compatible. Commit: [b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6).
|
||||||
|
|
||||||
|
1. **Complete key data required for master-password changes.** Password changes now reject incomplete or inconsistent authentication and unlock data, require the newly wrapped user key, and prevent KDF settings from being changed through the password-only endpoint. This avoids accepting a password update that could leave the account unable to decrypt its vault. Commit: [19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c).
|
||||||
|
|
||||||
|
1. **Extended vault item imports preserve their data.** Web imports now correctly map bank accounts, driver's licenses, and passports instead of reducing item types 6-8 to incomplete generic drafts. Import summaries also report these item types by name. Commit: [e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab).
|
||||||
|
|
||||||
|
1. **Reliable bulk folder deletion.** Bulk folder deletion now calculates Cloudflare D1 bind limits correctly and batches the related cipher cleanup and folder removal statements safely, preventing large selections from failing or leaving partially updated folder references. Commit: [63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca).
|
||||||
|
|
||||||
|
1. **Fresh remote backup directory listings.** Opening a remote backup destination now automatically refreshes directory data when the saved cache is older than five minutes, so newly created backup files appear without requiring a manual refresh. Commit: [72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
1. **Web Vault 显示开关。** Cloudflare Workers 部署现在可以设置 `HIDE_WEB_VAULT=1`,让服务器托管的 Web Vault 页面和静态资源统一返回 `404 Not Found`,同时继续提供 Bitwarden 客户端所需的登录、同步、附件、图标、通知和兼容接口。删除变量即可恢复 Web Vault,已经安装或缓存的 PWA 仍可继续使用本地前端。提交:[d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **更简洁的桌面端和移动端导航。** Web Vault 现在统一使用分组侧边栏,并会保存各分组的展开状态;工具、设置和系统管理的层级更加清楚,移动端设置入口也同步调整。原有布局模式选择器已移除,常用功能更容易查找。提交:[e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d)。
|
||||||
|
|
||||||
|
1. **更安全的 YubiKey 验证凭据管理。** Yubico 验证凭据现在通过统一且可防并发冲突的流程初始化;只有状态正常的管理员能够查看或替换服务器凭据,初始化和重新配置操作也会写入安全审计日志。普通用户仍可绑定 YubiKey,但无法接触服务器级凭据。提交:[573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437)。
|
||||||
|
|
||||||
|
1. **兼容 Bitwarden 的个人 API Key 与更安全的备份。** 用户现在可以在验证主密码后查看个人 API Key,并在不撤销无关会话的情况下轮换密钥。旧版哈希密钥在用户主动轮换前仍可继续认证;新备份不再包含个人 API Key、运行中的认证状态或设备信任状态,旧备份仍能导入,但其中这类运行时状态会被主动忽略。提交:[299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **临时刷新失败导致意外退出登录。** 刷新会话现在根据客户端采用滑动有效期,并保留绝对最长时限;临时网络或服务故障不会再把已锁定的 Web Vault 直接变成完整登录页。Web Vault 会保留锁定会话并提供重试入口,同时兼容官方桌面端、移动端、浏览器扩展和 Web Cookie 会话。提交:[b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6)。
|
||||||
|
|
||||||
|
1. **修改主密码时必须提交完整密钥数据。** 密码修改接口现在会拒绝不完整或不一致的认证与解锁数据,强制要求新的用户密钥包装结果,并禁止通过仅修改密码的接口顺带更改 KDF 设置,避免出现密码已更新但保险库无法解密的账户状态。提交:[19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c)。
|
||||||
|
|
||||||
|
1. **扩展保险库条目导入时完整保留数据。** Web 导入现在会正确映射银行账户、驾驶证和护照,不再把类型 6-8 转换为字段缺失的通用草稿;导入结果摘要也会按名称显示这些条目类型。提交:[e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab)。
|
||||||
|
|
||||||
|
1. **可靠的批量文件夹删除。** 批量删除文件夹时现在会正确计算 Cloudflare D1 的绑定参数上限,并安全批处理密码条目引用清理和文件夹删除语句,避免大量选择时失败或留下只更新了一部分的文件夹引用。提交:[63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca)。
|
||||||
|
|
||||||
|
1. **远端备份目录自动显示最新文件。** 打开远端备份目标时,如果已保存的目录缓存超过五分钟,页面会自动获取最新数据,新生成的备份文件无需手动点击刷新即可出现。提交:[72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4)。
|
||||||
|
|
||||||
|
# v1.7.4 - Password Tools, Localization, and Security Hardening
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **Built-in password generator.** The web vault now provides a dedicated generator for creating strong, configurable passwords, including memorable passphrases backed by the EFF word list. Generated values can be used while creating or editing vault items without leaving the app. Commits: [dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3), [fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428).
|
||||||
|
|
||||||
|
1. **Password security dashboard.** A new password-security view scans the vault and reports weak, reused, exposed, and missing two-factor-authentication passwords, helping users prioritize account cleanup. Commit: [99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9).
|
||||||
|
|
||||||
|
1. **Duplicate-item selection tools.** Duplicate results are grouped by color and sorted alphabetically, and the vault now lets users select the unique items from a duplicate group for quicker review and cleanup. Commits: [0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c), [39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118).
|
||||||
|
|
||||||
|
1. **Five additional interface languages.** Finnish, German, French, Italian, and Swedish are now available in the web vault, expanding the supported interface languages to ten. Commits: [dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329), [9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **Passkey unlock and Bitwarden-client compatibility.** Account passkeys can now unlock the web vault directly, and supported FIDO2 origins, worker-routed fill-assist, Android asset-links checks, and web bootstrap paths are recognized more consistently. Commits: [8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7), [db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2).
|
||||||
|
|
||||||
|
1. **Clearer offline and Send experiences.** The app now clearly indicates when it is operating offline, Send pages use improved date formatting, and closing dialogs retain their title through the animation to avoid a visual flash. Commits: [aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072), [04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744), [525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b).
|
||||||
|
|
||||||
|
1. **Simplified new-device sign-in.** New-device verification is no longer enforced, removing an extra login step for devices that are otherwise able to authenticate successfully. Commit: [14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **Backup destination SSRF protection.** Backup destination validation now rejects IPv6 loopback addresses, closing a path that could otherwise bypass loopback-host safeguards. Commit: [3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8).
|
||||||
|
|
||||||
|
1. **Sensitive admin actions require the master password.** Administrative operations and device-wipe actions now require master-password confirmation, reducing the impact of an unattended or compromised web session. Commit: [fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
1. **内置密码生成器。** Web 保险库现在提供专用的密码生成器,可创建高强度且可配置的密码,也支持基于 EFF 词表的易记口令短语。生成的值可直接用于新建或编辑保险库条目,无需离开应用。提交:[dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3)、[fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428)。
|
||||||
|
|
||||||
|
1. **密码安全仪表板。** 新增的密码安全视图会扫描保险库,并报告弱密码、重复密码、已泄露密码和缺少双因素认证的密码,帮助用户优先处理需要清理的账户。提交:[99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9)。
|
||||||
|
|
||||||
|
1. **重复条目选择工具。** 重复项结果会按颜色分组并按字母顺序排序;保险库现在还可以从重复组中选择唯一条目,以便更快地审查和清理。提交:[0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c)、[39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118)。
|
||||||
|
|
||||||
|
1. **新增五种界面语言。** Web 保险库现在提供芬兰语、德语、法语、意大利语和瑞典语,支持的界面语言扩展至十种。提交:[dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329)、[9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **通行密钥解锁和 Bitwarden 客户端兼容性。** 账户通行密钥现在可以直接解锁 Web 保险库;同时,对受支持 FIDO2 来源、Worker 路由的 fill-assist、Android asset-links 检查和 Web 启动路径的识别更加一致。提交:[8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7)、[db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2)。
|
||||||
|
|
||||||
|
1. **更清晰的离线和 Send 使用体验。** 应用现在会明确提示离线运行状态,Send 页面采用了更好的日期格式,关闭对话框时会在动画期间保留标题,避免视觉闪烁。提交:[aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072)、[04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744)、[525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b)。
|
||||||
|
|
||||||
|
1. **简化新设备登录。** 不再强制执行新设备验证,能够正常完成身份验证的设备无需额外登录步骤。提交:[14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **备份目标的 SSRF 防护。** 备份目标校验现在会拒绝 IPv6 回环地址,堵住了可能绕过回环主机保护的路径。提交:[3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8)。
|
||||||
|
|
||||||
|
1. **敏感管理员操作需要主密码。** 管理员操作和设备擦除操作现在需要确认主密码,降低无人值守或会话遭入侵时的影响。提交:[fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2)。
|
||||||
|
|
||||||
|
# v1.7.3 - Stronger Two-Step Login and Client Compatibility
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **YubiKey OTP and passkey two-step login.** NodeWarden now supports YubiKey OTP as a managed two-factor provider and adds passkey-based two-factor authentication, including setup screens, WebAuthn fallback connector handling, multi-provider login prompts, and safer WebAuthn response normalization. Commits: [f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745), [c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93), [e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d), [d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d).
|
||||||
|
|
||||||
|
1. **Bitwarden extended vault item types.** Vault items now cover bank accounts, driver's licenses, and passports in addition to the existing login, card, identity, secure note, and SSH key flows. The web vault can create, display, decrypt, import, and export these item types with clearer sidebar icons. Commits: [109593d](https://github.com/shuaiplus/nodewarden/commit/109593d), [9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b).
|
||||||
|
|
||||||
|
1. **More Bitwarden client compatibility endpoints.** Added device verification settings, device registration routes, admin auth-request compatibility, fill-assist alignment, and push relay installation handling so more official Bitwarden client flows receive expected responses. Unsupported email verification and KDF routes now return explicit unsupported responses instead of ambiguous failures. Commits: [e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84), [8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b), [f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233), [56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f), [fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff), [cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **TOTP QR scanning and Bitwarden-compatible TOTP behavior.** Uploading TOTP QR codes now falls back to `jsQR` when browser `BarcodeDetector` support is incomplete, handles transparent PNGs correctly, validates uploaded QR images, and throttles camera fallback decoding to reduce CPU usage. TOTP storage and decryption behavior is also aligned more closely with Bitwarden clients. Commits: [b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b), [d024798](https://github.com/shuaiplus/nodewarden/commit/d024798), [73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b), [6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220), [8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210).
|
||||||
|
|
||||||
|
1. **Settings, device management, and localization polish.** Device management now lives inside Settings with updated navigation, the two-step provider UI is more responsive, and new settings, audit-log, and validation messages are localized across supported languages. This makes the security settings area easier to scan on desktop and mobile. Commits: [c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6), [062c966](https://github.com/shuaiplus/nodewarden/commit/062c966), [12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e), [c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f), [01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627).
|
||||||
|
|
||||||
|
1. **Encrypted Send password visibility and editing.** Password-protected Sends now show a lock indicator in the list, display masked password dots when editing an existing protected Send, and provide a compact trash-icon control for removing the stored password. This makes password state visible without exposing the password itself. Commits: [a870142](https://github.com/shuaiplus/nodewarden/commit/a870142), [ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e).
|
||||||
|
|
||||||
|
1. **Website icon behavior and workflow maintenance.** Website icons are now always available without the old `WEBSITE_ICONS_ENABLED` environment toggle, while icon requests keep privacy protections. The global-domains sync workflow also validates its ref before running. Commits: [57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9), [c643874](https://github.com/shuaiplus/nodewarden/commit/c643874), [680e287](https://github.com/shuaiplus/nodewarden/commit/680e287).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **Authentication, token, and rate-limit hardening.** API keys are stored as hashes, password rotation and JWT handling were tightened, user cache invalidates on token handling, remembered 2FA tokens survive a bad password attempt, the current access-token session is revoked correctly, and known rate-limit reset bypasses were closed. Commits: [1545881](https://github.com/shuaiplus/nodewarden/commit/1545881), [439683d](https://github.com/shuaiplus/nodewarden/commit/439683d), [60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298), [d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe), [1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f), [2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc), [ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be).
|
||||||
|
|
||||||
|
1. **User data isolation and request validation.** Storage reads are scoped by user, Send file routes gate access more strictly, anonymous notification hub requests are validated, and multipart backup/upload requests now have caps. This reduces the chance of cross-user data reads or oversized requests reaching deeper handlers. Commits: [baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699), [8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1), [23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd), [5142846](https://github.com/shuaiplus/nodewarden/commit/5142846).
|
||||||
|
|
||||||
|
1. **Backup, restore, and download safety.** Remote backup deletes are verified, archives and backup blobs are validated before use, destination secrets are redacted from settings responses, backup/download token flows are harder to misuse, and WebAuthn credential purpose survives backup export/import. A backup uploader redirect guard was also reverted to restore compatible remote behavior. Commits: [0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0), [00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0), [5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b), [cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830), [f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a), [a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb).
|
||||||
|
|
||||||
|
1. **Import compatibility and encrypted-field validation.** Imports now validate payload structure and ZIP entries before processing, and plaintext FIDO2 credential, SSH key, and password-history fields are rejected instead of being silently accepted and later dropped. This makes failed imports clearer and protects encrypted vault fields from incompatible plaintext data. Commits: [cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704), [1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4).
|
||||||
|
|
||||||
|
1. **Admin, audit, WebAuthn, and backup endpoint edge cases.** Admin audit-clears are recorded, passkey 2FA status is reported correctly, WebAuthn extension origins are constrained, and auth-request plus backup endpoint checks were tightened around sensitive flows. Commits: [d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19), [ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8), [7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
1. **YubiKey OTP 和通行密钥两步登录。** NodeWarden 现在支持将 YubiKey OTP 作为可管理的双因素提供商,并新增基于通行密钥的双因素认证,包含设置界面、WebAuthn 备用连接器处理、多提供商登录提示,以及更安全的 WebAuthn 响应规范化。提交:[f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745)、[c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93)、[e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d)、[d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d)。
|
||||||
|
|
||||||
|
1. **Bitwarden 扩展保险库条目类型。** 除现有登录、银行卡、身份、安全笔记和 SSH 密钥流程外,保险库条目现在还覆盖银行账户、驾驶证和护照。Web 保险库可以创建、展示、解密、导入和导出这些条目类型,并提供更清晰的侧边栏图标。提交:[109593d](https://github.com/shuaiplus/nodewarden/commit/109593d)、[9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b)。
|
||||||
|
|
||||||
|
1. **更多 Bitwarden 客户端兼容端点。** 新增设备验证设置、设备注册路由、管理员认证请求兼容、fill-assist 对齐和推送中继安装处理,让更多官方 Bitwarden 客户端流程能获得预期响应。不支持的邮箱验证和 KDF 路由现在会返回明确的不支持响应,而不是含糊失败。提交:[e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84)、[8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b)、[f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233)、[56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f)、[fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff)、[cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **TOTP 二维码扫描和 Bitwarden 兼容 TOTP 行为。** 上传 TOTP 二维码时,如果浏览器 `BarcodeDetector` 支持不完整,现在会回退到 `jsQR`,并正确处理透明 PNG、校验上传的二维码图片、限制摄像头回退解码频率以降低 CPU 占用。TOTP 的存储和解密行为也更贴近 Bitwarden 客户端。提交:[b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b)、[d024798](https://github.com/shuaiplus/nodewarden/commit/d024798)、[73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b)、[6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220)、[8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210)。
|
||||||
|
|
||||||
|
1. **设置、设备管理和本地化打磨。** 设备管理现在整合进设置页并更新了导航,两步验证提供商界面在响应式布局下更顺手,新的设置、审计日志和校验消息也补齐了受支持语言的本地化。安全设置区域在桌面和移动端都更容易浏览。提交:[c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6)、[062c966](https://github.com/shuaiplus/nodewarden/commit/062c966)、[12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e)、[c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f)、[01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627)。
|
||||||
|
|
||||||
|
1. **加密 Send 的密码状态展示与编辑。** 受密码保护的 Send 现在会在列表中显示锁定标记,编辑已有受保护 Send 时会显示密码掩码圆点,并提供紧凑的垃圾桶图标用于移除已保存密码。这样可以看清密码状态,同时不暴露密码本身。提交:[a870142](https://github.com/shuaiplus/nodewarden/commit/a870142)、[ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e)。
|
||||||
|
|
||||||
|
1. **网站图标行为和工作流维护。** 网站图标现在无需旧的 `WEBSITE_ICONS_ENABLED` 环境开关即可始终可用,同时图标请求仍保留隐私保护。global-domains 同步工作流也会在运行前校验引用。提交:[57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9)、[c643874](https://github.com/shuaiplus/nodewarden/commit/c643874)、[680e287](https://github.com/shuaiplus/nodewarden/commit/680e287)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **认证、令牌和速率限制加固。** API key 现在以哈希形式存储,密码轮换和 JWT 处理更严格,令牌处理时会使用户缓存失效,错误密码不会丢失已记住的 2FA token,当前访问令牌会被正确撤销,并关闭了已知的速率限制重置绕过路径。提交:[1545881](https://github.com/shuaiplus/nodewarden/commit/1545881)、[439683d](https://github.com/shuaiplus/nodewarden/commit/439683d)、[60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298)、[d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe)、[1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f)、[2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc)、[ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be)。
|
||||||
|
|
||||||
|
1. **用户数据隔离和请求校验。** 存储读取现在按用户限定范围,Send 文件路由更严格地拦截访问,匿名通知 hub 请求会被校验,并且多段备份/上传请求增加了上限。这降低了跨用户数据读取或超大请求进入深层处理器的风险。提交:[baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699)、[8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1)、[23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd)、[5142846](https://github.com/shuaiplus/nodewarden/commit/5142846)。
|
||||||
|
|
||||||
|
1. **备份、恢复和下载安全性。** 远端备份删除现在会被验证,归档和备份 blob 使用前会校验,目标配置里的密钥会在设置响应中脱敏,备份/下载令牌流程更难被误用,WebAuthn 凭据用途也会在备份导出/导入中保留。备份上传器的重定向防护也已回退,以恢复兼容的远端行为。提交:[0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0)、[00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0)、[5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b)、[cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830)、[f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a)、[a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb)。
|
||||||
|
|
||||||
|
1. **导入兼容性和加密字段校验。** 导入流程现在会在处理前校验 payload 结构和 ZIP 条目,明文 FIDO2 凭据、SSH 密钥和密码历史字段会被拒绝,而不是先被静默接受再在响应时丢弃。这让失败导入更清楚,也保护加密保险库字段不接收不兼容的明文数据。提交:[cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704)、[1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4)。
|
||||||
|
|
||||||
|
1. **管理员、审计、WebAuthn 和备份端点边界情况。** 管理员清空审计日志会被记录,通行密钥 2FA 状态会正确上报,WebAuthn 扩展来源会受到限制,并且认证请求与备份端点围绕敏感流程的校验也更严格。提交:[d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19)、[ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8)、[7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v1.7.2 - New Backup Providers, WebAuthn PRF, and UI Polish
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **Three new S3-compatible backup providers: Backblaze B2, Cloudflare R2, and Tigris.** Each new destination comes with detailed provider-specific recommendations, storage-class guidance, and localization strings across all five supported languages. You can now back up to more services without custom scripting. Commits: [1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e), [c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b), [ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698).
|
||||||
|
|
||||||
|
2. **WebAuthn PRF (pseudorandom function) extension support.** Credential creation and assertion now pass browser-compatible PRF extension requests, support excluding PRF extensions where the client doesn't need them, and handle the underlying passkey operations more robustly. This improves WebAuthn compatibility with modern browsers and password managers that rely on PRF for per-credential keys. Commits: [8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b), [31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19), [6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835), [bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b).
|
||||||
|
|
||||||
|
3. **Backup import locking and checksum verification.** Restoring a full backup now acquires an exclusive lock so concurrent imports cannot collide, and the importer verifies file checksums before applying the data. This makes disaster recovery safer when multiple admins might trigger restores. Commit: [e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec).
|
||||||
|
|
||||||
|
4. **Fullscreen layout toggle.** The web vault can now switch to fullscreen mode with a dedicated toggle button, with corresponding localization updates. Useful for kiosk-mode or presentation setups. Commit: [d722815](https://github.com/shuaiplus/nodewarden/commit/d722815).
|
||||||
|
|
||||||
|
5. **Fill-assist API handlers.** NodeWarden now implements Bitwarden-compatible credential fill-assist endpoints, letting clients fetch credentials inline via the new `POST /fill-assist` route. Device response types are also updated to include the fields needed by the fill-assist flow. Commit: [e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4).
|
||||||
|
|
||||||
|
6. **Device selection and removal in SecurityDevicesPage.** The security devices panel now supports selecting individual trusted devices and removing them directly from the web UI, so you no longer need to use the API to revoke a specific device. Commit: [a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a).
|
||||||
|
|
||||||
|
7. **Delete invalid organization invitations.** Admins can now detect and remove dangling or invalid invitations from the admin panel, helping keep the invitation list clean. The API also renamed `revokeInvite` to `deleteInvite` for clearer semantics. Commits: [0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19), [f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3).
|
||||||
|
|
||||||
|
8. **validFolderIds support in cipher responses.** Sync and cipher responses now include a `validFolderIds` field so clients can distinguish real folders from orphaned references. The folder repository also validates folder existence more strictly. Commit: [82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e).
|
||||||
|
|
||||||
|
9. **Pending auth request loading state.** The pending login-request panel shows a refreshing indicator while fetching or updating the request list, providing clearer feedback during auth request workflows. Commit: [4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **Enhanced Bitwarden CSV import with custom field and multiline support.** The CSV parser now recognizes custom fields and restores their metadata correctly during import. It also preserves multiline values such as SSH private keys—previously, any line without a `: ` delimiter was silently dropped, truncating private keys to the first line. Text fields containing newlines now survive a full export-import round-trip. Commits: [5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e), [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
|
||||||
|
|
||||||
|
2. **Consolidated security devices UI.** Device management and authorized devices sections are merged into a single coherent card on SecurityDevicesPage, and the pending-auth-requests panel has been removed from the general SettingsPage to reduce clutter. The device list also includes improved selection controls. Commit: [c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b).
|
||||||
|
|
||||||
|
3. **Refined app-shell styles and dark mode consistency.** Removed redundant global styles, cleaned up shell component spacing, and improved dark-mode visual consistency across the header, sidebar, and main content areas. Commit: [1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6).
|
||||||
|
|
||||||
|
4. **Backup and restore error messages across all locales.** New error strings for backup/restore edge cases—lock failures, checksum mismatches, missing files—are now localized in all five supported languages (en, es, ru, zh-CN, zh-TW), with improved UI prompts for backup browser refresh scenarios. Commit: [4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0).
|
||||||
|
|
||||||
|
5. **Updated project wiki link and removed obsolete security scripts.** The issue-template wiki link now points to the correct URL, and the old local security scanning scripts and workflows have been removed in favor of GitHub-native security automation (CodeQL, security-extra workflows). Commit: [e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c).
|
||||||
|
|
||||||
|
6. **Security automation and dependency hardening.** Added GitHub-native CodeQL and security-extra workflows, overrode a `ws` vulnerability, and upgraded CI actions to pinned major versions (checkout v7, setup-node v6, create-pull-request v8). Dependencies refreshed include TypeScript 6.0, `@types/node` 26, `lucide-preact` 1.x, and many others across npm and GitHub Actions. Commits: [64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7), [32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a), [5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff), [8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca), [5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab), [99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f), [fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae), [c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **CSV import truncating multiline field values.** `parseBitwardenCsvFieldLines` previously discarded any line that did not contain a `: ` delimiter, silently dropping SSH private keys and other multiline content to only the first line. The parser now accumulates continuation lines correctly, restoring full private key content through a CSV round-trip. Commit: [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
1. **三个新的 S3 兼容备份提供商:Backblaze B2、Cloudflare R2 和 Tigris。** 每个新目标都带有详细的提供商建议、存储层级指导和五种语言的本地化字符串,无需额外脚本即可将备份扩展到更多存储服务。提交:[1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e)、[c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b)、[ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698)。
|
||||||
|
|
||||||
|
2. **WebAuthn PRF(伪随机函数)扩展支持。** 创建和断言凭证时会传递浏览器兼容的 PRF 扩展请求,支持在不需要时排除 PRF 扩展,并且底层密钥操作更健壮。这改善了与依赖 PRF 做每凭据密钥派生功能的现代浏览器和密码管理器的兼容性。提交:[8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b)、[31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19)、[6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835)、[bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b)。
|
||||||
|
|
||||||
|
3. **备份导入加锁和校验和验证。** 完整恢复备份时现在会获取独占锁,防止并发导入冲突;导入前还会验证文件校验和再应用数据。多管理员可能同时触发恢复时,该机制让灾难恢复更加安全。提交:[e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec)。
|
||||||
|
|
||||||
|
4. **全屏布局切换。** Web 保险库现在可以通过专用按钮切换全屏模式,附带对应本地化更新。适合信息亭模式或展示等场景。提交:[d722815](https://github.com/shuaiplus/nodewarden/commit/d722815)。
|
||||||
|
|
||||||
|
5. **Fill-assist API 处理器。** NodeWarden 现在实现了与 Bitwarden 兼容的凭据填充辅助端点,客户端可以通过新的 `POST /fill-assist` 路由内联获取凭据。设备响应类型也补上了 fill-assist 流程需要的字段。提交:[e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4)。
|
||||||
|
|
||||||
|
6. **安全设备页的设备选择与删除。** 设备面板现在支持在 Web UI 中直接选择单个可信设备并移除,无需通过 API 手动撤销指定设备。提交:[a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a)。
|
||||||
|
|
||||||
|
7. **删除无效邀请码。** 管理员现在可以在管理面板中检测并删除悬空或无效的邀请,保持邀请列表整洁。API 也将 `revokeInvite` 改名为 `deleteInvite`,语义更清晰。提交:[0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19)、[f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3)。
|
||||||
|
|
||||||
|
8. **密码条目响应增加 validFolderIds。** 同步和密码条目响应现在包含 `validFolderIds` 字段,方便客户端区分真实文件夹和孤立引用;文件夹存储也加强了对文件夹存在性的校验。提交:[82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e)。
|
||||||
|
|
||||||
|
9. **待处理认证请求的加载状态。** 待处理的登录请求面板现在会在获取或更新请求列表时显示刷新指示器,为认证请求操作提供更清晰的反馈。提交:[4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **增强的 Bitwarden CSV 导入——自定义字段和多行支持。** CSV 解析器现在可以识别自定义字段并在导入时正确恢复其元数据。同时保留了 SSH 私钥等多行值——之前任何不带 `: ` 分隔符的行都会被丢弃,导致私钥只保留第一行。包含换行符的文本字段现在可以完整通过导出-导入周期。提交:[5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e)、[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
|
||||||
|
|
||||||
|
2. **整合安全设备界面。** 设备管理和已授权设备两个部分合并为 SecurityDevicesPage 上的一个统一卡片;待处理认证请求面板从 SettingsPage 中移除以减少杂乱。设备列表也改进了选择操作。提交:[c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b)。
|
||||||
|
|
||||||
|
3. **精简应用外壳样式与暗色模式一致性。** 移除了冗余全局样式,清理了外壳组件间距,改善了头部、侧边栏和主内容区在暗色模式下的视觉一致性。提交:[1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6)。
|
||||||
|
|
||||||
|
4. **备份/恢复错误消息全语言本地化。** 备份/恢复边界场景(加锁失败、校验和不匹配、文件缺失)的新错误字符串已在五种支持语言(en、es、ru、zh-CN、zh-TW)中完成本地化,同时改进了备份浏览器刷新场景下的界面提示。提交:[4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0)。
|
||||||
|
|
||||||
|
5. **更新项目 Wiki 链接并移除过时安全脚本。** 议题模板中的 Wiki 链接已指向正确 URL;老旧的本地安全扫描脚本和工作流已移除,改用 GitHub 原生安全自动化(CodeQL、security-extra 工作流)。提交:[e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c)。
|
||||||
|
|
||||||
|
6. **安全自动化和依赖加固。** 新增 GitHub 原生 CodeQL 和 security-extra 工作流;覆盖了 `ws` 的已知漏洞;将 CI Action 升级到钉死的主要版本(checkout v7、setup-node v6、create-pull-request v8)。依赖升级包括 TypeScript 6.0、`@types/node` 26、`lucide-preact` 1.x,以及 npm 和 GitHub Actions 的多项更新。提交:[64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7)、[32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a)、[5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff)、[8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca)、[5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab)、[99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f)、[fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae)、[c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **CSV 导入截断多行字段值。** `parseBitwardenCsvFieldLines` 之前会丢弃任何不包含 `: ` 分隔符的行,导致 SSH 私钥等多行内容被静默截断为仅第一行。解析器现已正确累积后续行,使私钥等完整内容能够通过 CSV 导出-导入周期完好保留。提交:[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
|
||||||
|
|
||||||
|
# v1.7.1 - Security Hardening Update
|
||||||
|
|
||||||
|
Thanks to GN998 for responsibly reporting security issues addressed in this release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **No new user-facing features.** This patch release intentionally focuses on security fixes and defensive hardening rather than new product functionality. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **Stronger security defaults.** NodeWarden now applies more conservative handling around sensitive authentication, backup, and file-delivery flows while keeping existing clients compatible. Upgrade is recommended for all deployments. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **High-priority security fixes.** This release closes multiple reported security issues across sensitive server-side flows and response hardening without exposing operational details in the public notes. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
|
||||||
|
|
||||||
|
2. **Security dependency overrides.** Package overrides were added for selected transitive dependencies so installs resolve to patched versions where applicable. Commit: [0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
感谢 GN998 负责任地报告了本次发布中修复的安全问题。
|
||||||
|
|
||||||
|
1. **没有新增面向用户的功能。** 本次补丁发布刻意专注于安全修复和防护加固,不包含新的产品功能。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **更稳妥的安全默认行为。** NodeWarden 对敏感认证、备份和文件响应流程采用了更保守的处理方式,同时保持现有客户端兼容。建议所有部署尽快升级。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **高优先级安全修复。** 本次发布修复了多项已报告的安全问题,覆盖敏感服务端流程和响应加固;公开说明中不会展开可操作的攻击细节。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
|
||||||
|
|
||||||
|
2. **安全依赖覆盖。** 为部分传递依赖添加了版本覆盖,让安装时尽可能解析到已修复版本。提交:[0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46)。
|
||||||
|
|
||||||
|
# v1.7.0 - Faster Multi-Device Sync, Mobile Push, and a Smoother Vault
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
1. **Resource-level realtime sync.** NodeWarden now sends Bitwarden-style notifications for cipher, folder, and Send create, update, and delete events. The web app can refresh only the affected resource instead of reloading the full vault every time, and state-changing operations such as attachment uploads, attachment deletes, public Send access counts, and Send file downloads also emit the right updates. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f), [46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9), [f096681](https://github.com/shuaiplus/nodewarden/commit/f096681).
|
||||||
|
|
||||||
|
2. **Bitwarden mobile push relay support.** Devices can now store `push_uuid` and `push_token`, register or unregister through the Bitwarden push relay, and receive mobile push notifications when vault resources change. The database schema includes the new push fields and indexes needed to detect push-capable devices. Commit: [79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9).
|
||||||
|
|
||||||
|
3. **Bitwarden CSV export.** The web app can now export a Bitwarden-compatible CSV file alongside the existing JSON, encrypted JSON, and attachment ZIP formats. Multiple login URIs are serialized safely, and non-login item types such as cards, identities, and SSH keys are preserved as clearly as possible in field text. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e).
|
||||||
|
|
||||||
|
4. **More duplicate detection modes.** Duplicate search can now compare exact item content, login site plus credentials, username plus password, or password alone. Duplicate groups are color-coded, and the mobile vault UI includes the new duplicate mode selector and improved filter controls. Commits: [7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
|
||||||
|
|
||||||
|
5. **S3 addressing style selection.** Backup destinations can now choose between path-style and virtual-hosted-style S3 URLs, improving compatibility with more S3-compatible providers and self-hosted object storage. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
1. **Web vault updates feel immediate.** Creating, editing, deleting, archiving, restoring, and moving items; creating or deleting folders; and creating, updating, or deleting Sends now update the local encrypted snapshot, decrypted lists, and revision timestamp directly. This reduces visible lag after successful actions and makes cached vault validation work better with resource-level sync. Commits: [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f).
|
||||||
|
|
||||||
|
2. **Better Bitwarden client compatibility.** Profile and sync responses now include fields such as `organizationsNew`, `policiesNew`, and `V2UpgradeToken`; `/api/accounts/keys` supports GET; password change and password verification accept newer `authenticationData` and `unlockData` request shapes; and device routes work with both `/api/devices` and `/devices`. Cipher responses also preserve stored `edit`, `viewPassword`, and `permissions` flags instead of resetting them. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532).
|
||||||
|
|
||||||
|
3. **Cleaner mobile and narrow-screen UI.** Topbar controls, network status, theme switching, and lock buttons now share more consistent sizing and styling. The vault list search, sorting, filtering, create button, and bulk selection toolbar are more compact on mobile, and mobile filter menus can switch between all items, favorites, archive, trash, duplicates, types, and folders. Commits: [7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f), [16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22), [cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82), [c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795).
|
||||||
|
|
||||||
|
4. **More tolerant TOTP handling.** TOTP codes are grouped more naturally for 5-digit, 6-digit, 8-digit, and other lengths, and the TOTP list no longer overflows narrow screens because of fixed column widths. `otpauth://` parsing is also more tolerant of unusual parameter encoding, with more stable Steam-code detection. Commits: [9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f), [d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2).
|
||||||
|
|
||||||
|
5. **Less jumpy network status.** The web app no longer switches offline after one short failed probe. It uses a longer timeout, waits for repeated failures, and lets normal API successes or failures update the network state, reducing false offline unlock fallbacks when the service is reachable but slow. Commit: [b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04).
|
||||||
|
|
||||||
|
6. **More complete backups.** Full instance backups now include trusted two-factor device tokens and restore them during import. The importer validates token ownership, device identifiers, expiration times, and duplicates, so remembered two-factor devices can survive a full migration. Commit: [f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
1. **Realtime notification correctness.** Resource notification type numbers now match Bitwarden semantics, while NodeWarden-specific device status and backup progress notifications use internal values to avoid conflicts with official Send update types. SignalR MessagePack invocations now include `streamIds`, pending auth request notifications refresh the login request list, and the web app ignores notifications sent by the current device to avoid redundant refreshes. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504), [4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0).
|
||||||
|
|
||||||
|
2. **Attachment and Send download details.** Public attachment and Send file downloads now include `Content-Disposition` filenames and `X-Content-Type-Options: nosniff`, making browser downloads keep better filenames and reducing content sniffing issues. Attachment delete responses now include both uppercase and lowercase field forms for broader client compatibility. Commit: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b).
|
||||||
|
|
||||||
|
3. **Deleted item and bulk action edge cases.** Vault paging now detects deleted items from both database columns and older JSON payload fields, preventing old deleted items from appearing in the normal vault list. Bulk archive skips deleted items, and duplicate detection now uses decrypted password history instead of encrypted stored text. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
|
||||||
|
|
||||||
|
4. **Export, dialog, and toast polish.** CSV export now escapes login URIs correctly inside a single CSV cell; some dialog dismissal behavior is more stable; login and unlock success toasts are less noisy; and the toast close button now uses a styled SVG icon. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e), [8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f), [907126d](https://github.com/shuaiplus/nodewarden/commit/907126d).
|
||||||
|
|
||||||
|
5. **S3 backup URL construction.** Virtual-hosted-style backup operations now use the `bucket.endpoint` form for upload, download, delete, and existence checks, while avoiding duplicate bucket names when the endpoint already includes the bucket. Path-style mode keeps the existing `endpoint/bucket` behavior. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
1. **资源级实时同步。** NodeWarden 现在会按 Bitwarden 风格发送密码条目、文件夹、Send 的新增、更新和删除通知。Web 端收到通知后可以只刷新受影响的资源,而不是每次都重新同步整个保险库;附件上传、附件删除、公开 Send 访问计数、Send 文件下载等会改变状态的操作,也会触发对应更新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)、[46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9)、[f096681](https://github.com/shuaiplus/nodewarden/commit/f096681)。
|
||||||
|
|
||||||
|
2. **Bitwarden 移动端推送中继支持。** 设备现在可以保存 `push_uuid` 和 `push_token`,通过 Bitwarden push relay 注册或注销,并在保险库资源变化时尝试接收移动端推送。数据库结构也补上了推送字段和索引,用于识别哪些设备可以被推送。提交:[79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9)。
|
||||||
|
|
||||||
|
3. **Bitwarden CSV 导出。** 除了原有 JSON、加密 JSON 和带附件 ZIP 导出,现在 Web 端可以直接导出 Bitwarden 兼容 CSV。多个登录 URI 会按 CSV 规则安全序列化,卡片、身份、SSH Key 等非登录类型也会尽量保留到字段文本中,方便迁移或人工整理。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)。
|
||||||
|
|
||||||
|
4. **更多重复项检测模式。** 重复项现在可以按完全一致、登录站点加凭据、用户名加密码、单独密码等方式判断。重复组会用颜色辅助区分,移动端保险库也补上了重复项模式选择和更完整的筛选入口。提交:[7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
|
||||||
|
|
||||||
|
5. **S3 地址样式选择。** 远程备份目标现在可以选择 path-style 或 virtual-hosted-style,兼容更多 S3 服务和自建对象存储。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
1. **Web 保险库操作反馈更及时。** 创建、编辑、删除、归档、恢复、移动条目,创建或删除文件夹,以及创建、更新、删除 Send 时,前端会直接更新本地加密快照、解密列表和修订时间。这样操作成功后列表更快跟上,也让资源级同步下的本地缓存校验更稳定。提交:[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)。
|
||||||
|
|
||||||
|
2. **Bitwarden 客户端兼容性更好。** 账户资料和同步响应补齐了 `organizationsNew`、`policiesNew`、`V2UpgradeToken` 等字段;`/api/accounts/keys` 支持 GET;改密和校验密码接口兼容较新的 `authenticationData`、`unlockData` 请求结构;设备路由同时兼容 `/api/devices` 和 `/devices`。密码条目响应也会保留已存储的 `edit`、`viewPassword` 和 `permissions`,避免跨客户端编辑时权限标记被重置。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532)。
|
||||||
|
|
||||||
|
3. **移动端和小屏界面更顺手。** 顶部栏按钮、网络状态、主题切换、锁定按钮的尺寸和样式更统一。保险库列表里的搜索、排序、筛选、创建按钮和批量选择工具栏在移动端更紧凑;移动筛选菜单可以直接切换全部、收藏、归档、回收站、重复项、类型和文件夹。提交:[7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f)、[16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22)、[cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82)、[c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795)。
|
||||||
|
|
||||||
|
4. **TOTP 展示和解析更稳。** 验证码会按 5 位、6 位、8 位等不同长度更自然地分组,列表在窄屏下不会再被固定列宽撑破。`otpauth://` 解析也更能容忍特殊参数编码,Steam 类验证码识别更稳定。提交:[9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f)、[d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2)。
|
||||||
|
|
||||||
|
5. **网络状态不再过度敏感。** Web 端不会因为一次短暂探测失败就立刻判定离线,而是延长探测超时并等待连续失败;普通 API 请求成功或失败也会反向更新网络状态。在线但网络较慢时,不容易误进入离线解锁流程。提交:[b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04)。
|
||||||
|
|
||||||
|
6. **备份内容更完整。** 完整实例备份现在会导出和还原可信二步验证设备令牌。导入时会校验令牌所属用户、设备标识、过期时间和重复项,让“记住此设备”的二步验证状态在完整迁移后也能保留下来。提交:[f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
1. **实时通知类型和刷新逻辑。** 资源通知的类型编号调整为与 Bitwarden 官方语义一致,NodeWarden 自定义的设备状态和备份进度通知改用内部编号,避免和官方 Send 更新类型冲突。SignalR MessagePack 调用补齐了 `streamIds`,认证请求通知会刷新待处理登录请求列表,Web 端也会忽略当前设备自己发出的通知,避免重复刷新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504)、[4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0)。
|
||||||
|
|
||||||
|
2. **附件和 Send 文件下载细节。** 公开附件和 Send 文件下载现在会带上 `Content-Disposition` 文件名和 `X-Content-Type-Options: nosniff`,浏览器保存文件时更接近原文件名,也减少类型嗅探问题。删除附件的响应同时提供大小写两套字段,兼容不同客户端读取方式。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)。
|
||||||
|
|
||||||
|
3. **已删除条目和批量操作边界。** 保险库分页查询现在会同时识别数据库列和历史 JSON 数据里的删除时间,避免旧数据中已删除条目出现在正常列表。批量归档会跳过已删除条目,重复项判断也会使用已解密的密码历史,避免加密文本影响结果。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
|
||||||
|
|
||||||
|
4. **导出、弹窗和提示细节。** CSV 导出中的登录 URI 会按单行 CSV 单元格正确转义;部分弹窗关闭行为更稳定;登录或解锁成功后的 toast 更克制,避免重复提示;toast 关闭按钮换成了 SVG 图标并调整了样式。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)、[8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f)、[907126d](https://github.com/shuaiplus/nodewarden/commit/907126d)。
|
||||||
|
|
||||||
|
5. **S3 备份地址拼接。** 选择 virtual-hosted-style 时,备份上传、下载、删除和存在性检查会使用 `bucket.endpoint` 形式;如果 endpoint 已经带有 bucket,也不会重复拼接 bucket。path-style 仍保持原有 `endpoint/bucket` 形式。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Thank you for helping keep NodeWarden safe.
|
||||||
|
|
||||||
|
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
|
||||||
|
|
||||||
|
Use GitHub Private Vulnerability Reporting instead:
|
||||||
|
|
||||||
|
1. Open the NodeWarden repository on GitHub.
|
||||||
|
2. Go to **Security and quality**.
|
||||||
|
3. Click **Report a vulnerability**.
|
||||||
|
4. Submit the report privately.
|
||||||
|
|
||||||
|
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
Please include as much detail as possible:
|
||||||
|
|
||||||
|
* A clear description of the vulnerability.
|
||||||
|
* Steps to reproduce.
|
||||||
|
* Affected version, commit, or deployment method.
|
||||||
|
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
|
||||||
|
* Expected behavior and actual behavior.
|
||||||
|
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
|
||||||
|
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
|
||||||
|
|
||||||
|
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Security reports are welcome for issues affecting NodeWarden itself, including:
|
||||||
|
|
||||||
|
* Authentication and session handling.
|
||||||
|
* User authorization and cross-user access.
|
||||||
|
* Vault data, cipher sync, attachments, and Send.
|
||||||
|
* Import, export, backup, and restore.
|
||||||
|
* Passkey, WebAuthn, and two-factor authentication.
|
||||||
|
* Secret handling and provider credentials.
|
||||||
|
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
The following are usually out of scope:
|
||||||
|
|
||||||
|
* Issues only affecting third-party services or user infrastructure.
|
||||||
|
* Misconfigured personal deployments not caused by NodeWarden defaults.
|
||||||
|
* Social engineering or phishing.
|
||||||
|
* Denial-of-service testing.
|
||||||
|
* Scanner-only reports without a practical exploit path.
|
||||||
|
* Reports that only mention outdated dependencies without showing real impact.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
NodeWarden is maintained on a best-effort basis.
|
||||||
|
|
||||||
|
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
|
||||||
|
|
||||||
|
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Security fixes are generally provided for the latest release and the latest code on the default branch.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| -------------- | ---------------------- |
|
||||||
|
| Latest release | Yes |
|
||||||
|
| `main` branch | Yes |
|
||||||
|
| Older releases | Best effort |
|
||||||
|
| Modified forks | Not directly supported |
|
||||||
|
|
||||||
|
## Rewards
|
||||||
|
|
||||||
|
NodeWarden does not currently operate a paid bug bounty program.
|
||||||
+116
-10
@@ -1,8 +1,14 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
-- IMPORTANT:
|
-- IMPORTANT:
|
||||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
-- This is the initial D1 schema. Keep it in sync with
|
||||||
|
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
|
||||||
-- Any new table/column/index must be added to both places together.
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
--
|
||||||
|
-- WHEN CHANGING THIS:
|
||||||
|
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
|
||||||
|
-- - If the new table stores persistent data, update backup export/import.
|
||||||
|
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS config (
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -25,13 +31,23 @@ 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,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Per-user sync revision date
|
-- Per-user sync revision date
|
||||||
CREATE TABLE IF NOT EXISTS user_revisions (
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
@@ -59,6 +75,8 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -106,11 +124,19 @@ CREATE TABLE IF NOT EXISTS sends (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
expires_at INTEGER 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);
|
||||||
@@ -133,6 +159,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
actor_user_id TEXT,
|
actor_user_id TEXT,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'system',
|
||||||
|
level TEXT NOT NULL DEFAULT 'info',
|
||||||
target_type TEXT,
|
target_type TEXT,
|
||||||
target_id TEXT,
|
target_id TEXT,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
@@ -141,6 +169,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS devices (
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -151,12 +181,47 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
encrypted_user_key TEXT,
|
encrypted_user_key TEXT,
|
||||||
encrypted_public_key TEXT,
|
encrypted_public_key TEXT,
|
||||||
encrypted_private_key TEXT,
|
encrypted_private_key TEXT,
|
||||||
|
push_uuid TEXT,
|
||||||
|
push_token TEXT,
|
||||||
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
banned_at TEXT,
|
||||||
|
device_note TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id, device_identifier),
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
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_devices_user_updated ON devices(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
organization_id TEXT,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
request_device_identifier TEXT NOT NULL,
|
||||||
|
request_device_type INTEGER NOT NULL,
|
||||||
|
request_ip_address TEXT,
|
||||||
|
request_country_name TEXT,
|
||||||
|
response_device_identifier TEXT,
|
||||||
|
access_code TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
key TEXT,
|
||||||
|
master_password_hash TEXT,
|
||||||
|
approved INTEGER,
|
||||||
|
creation_date TEXT NOT NULL,
|
||||||
|
response_date TEXT,
|
||||||
|
authentication_date TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
|
||||||
|
ON auth_requests(user_id, creation_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
|
||||||
|
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
|
||||||
|
ON auth_requests(user_id, request_device_identifier, creation_date);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@@ -168,6 +233,55 @@ 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 (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL DEFAULT 'login',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
type TEXT,
|
||||||
|
aa_guid TEXT,
|
||||||
|
transports TEXT,
|
||||||
|
encrypted_user_key TEXT,
|
||||||
|
encrypted_public_key TEXT,
|
||||||
|
encrypted_private_key TEXT,
|
||||||
|
supports_prf INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
|
||||||
|
ON webauthn_credentials(credential_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||||
|
ON webauthn_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
|
||||||
|
ON webauthn_credentials(user_id, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||||
|
challenge_hash TEXT PRIMARY KEY,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
||||||
|
ON webauthn_challenges(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
|
||||||
|
ON webauthn_challenges(user_id, scope);
|
||||||
|
|
||||||
-- Rate limiting
|
-- Rate limiting
|
||||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
@@ -176,14 +290,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
|||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
|
||||||
identifier TEXT NOT NULL,
|
|
||||||
window_start INTEGER NOT NULL,
|
|
||||||
count INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (identifier, window_start)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||||
jti TEXT PRIMARY KEY,
|
jti TEXT PRIMARY KEY,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
|
|||||||
Generated
+2793
-1084
File diff suppressed because it is too large
Load Diff
+40
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.2",
|
"version": "1.8.0",
|
||||||
"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",
|
||||||
@@ -9,9 +9,20 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||||
|
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
|
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||||
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
|
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||||
|
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||||
|
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
||||||
|
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||||
|
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
||||||
|
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -36,26 +47,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"undici": ">=7.28.0",
|
||||||
|
"@babel/core": ">=7.29.6",
|
||||||
|
"esbuild": ">=0.28.1",
|
||||||
|
"ws": "8.21.0",
|
||||||
|
"sharp": "0.35.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.6",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^26.1.1",
|
||||||
"tsx": "^4.21.0",
|
"autoprefixer": "^10.5.4",
|
||||||
"typescript": "^5.9.3",
|
"opencc-js": "^1.4.1",
|
||||||
"vite": "^7.3.1",
|
"postcss": "^8.5.23",
|
||||||
"wrangler": "^4.71.0"
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"wrangler": "^4.114.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@zip.js/zip.js": "^2.8.34",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"fflate": "^0.8.3",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"jsqr": "1.4.0",
|
||||||
"fflate": "^0.8.2",
|
"lucide-preact": "^1.26.0",
|
||||||
"lucide-preact": "^0.575.0",
|
"preact": "^10.29.7",
|
||||||
"preact": "^10.28.4",
|
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"wouter": "^3.9.0"
|
"wouter": "^3.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildConfigResponse } from '../src/config-response';
|
||||||
|
|
||||||
|
test('config enables the official Bitwarden desktop settings dialog', () => {
|
||||||
|
const body = buildConfigResponse('https://vault.example.test');
|
||||||
|
|
||||||
|
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
|
||||||
|
assert.equal(body.environment.vault, 'https://vault.example.test');
|
||||||
|
assert.equal(body.object, 'config');
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Make `deploy:kv` idempotent across repeated builds.
|
||||||
|
*
|
||||||
|
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
|
||||||
|
* by name. The template ships without an id so fresh accounts can provision one
|
||||||
|
* on first deploy. In non-interactive builds, wrangler may try to create the
|
||||||
|
* same namespace again on later builds and fail with code 10014.
|
||||||
|
*/
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
|
||||||
|
const BINDING = 'ATTACHMENTS_KV';
|
||||||
|
|
||||||
|
const wrangler = (args) =>
|
||||||
|
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
|
||||||
|
|
||||||
|
function bindingBlockHasId(toml) {
|
||||||
|
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
|
||||||
|
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
|
||||||
|
return block ? /^\s*id\s*=/m.test(block) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedTitle(toml) {
|
||||||
|
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
|
||||||
|
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveId(title) {
|
||||||
|
const list = JSON.parse(wrangler('kv namespace list'));
|
||||||
|
const hit =
|
||||||
|
list.find((namespace) => namespace.title === title) ||
|
||||||
|
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
|
||||||
|
if (hit) {
|
||||||
|
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
|
||||||
|
return hit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = wrangler(`kv namespace create "${title}"`);
|
||||||
|
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
|
||||||
|
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
|
||||||
|
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
let toml = fs.readFileSync(CONFIG, 'utf8');
|
||||||
|
if (bindingBlockHasId(toml)) {
|
||||||
|
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = resolveId(expectedTitle(toml));
|
||||||
|
toml = toml.replace(
|
||||||
|
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
|
||||||
|
`$1\nid = "${id}"`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(CONFIG, toml);
|
||||||
|
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This list is the script-side locale source of truth. Keep it in sync with
|
||||||
|
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
|
||||||
|
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
|
||||||
|
|
||||||
|
const localeFiles = [
|
||||||
|
['en', 'en.ts', 'en', 'English'],
|
||||||
|
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
|
||||||
|
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||||
|
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||||
|
['es', 'es.ts', 'es', 'Spanish'],
|
||||||
|
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||||
|
['de', 'de.ts', 'de', 'German'],
|
||||||
|
['fr', 'fr.ts', 'fr', 'French'],
|
||||||
|
['it', 'it.ts', 'it', 'Italian'],
|
||||||
|
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function readLocale(fileName, variableName) {
|
||||||
|
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
|
||||||
|
code = code
|
||||||
|
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
|
||||||
|
.replace(/export default \w+;\s*$/m, '');
|
||||||
|
code += `\nresult = ${variableName};`;
|
||||||
|
const sandbox = { result: null };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(code, sandbox, { filename: fileName });
|
||||||
|
return sandbox.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocale(fileName, variableName, table, header) {
|
||||||
|
const body = JSON.stringify(table, null, 2);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(localeDir, fileName),
|
||||||
|
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
localeFiles,
|
||||||
|
localeDir,
|
||||||
|
readLocale,
|
||||||
|
writeLocale,
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This is the authoritative locale consistency gate. It checks key parity,
|
||||||
|
// placeholder parity, and accidental mostly-English locale files. Run after any
|
||||||
|
// user-facing text or locale-file change.
|
||||||
|
const locales = Object.fromEntries(
|
||||||
|
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
|
||||||
|
);
|
||||||
|
const base = locales.en;
|
||||||
|
const baseKeys = Object.keys(base).sort();
|
||||||
|
const placeholderRe = /\{\w+\}/g;
|
||||||
|
const errors = [];
|
||||||
|
const intentionallyEnglishKeys = new Set([
|
||||||
|
'txt_backup_destination_detail_note',
|
||||||
|
'txt_backup_protocol_webdav',
|
||||||
|
'txt_backup_protocol_s3',
|
||||||
|
'txt_backup_recommend_group_webdav',
|
||||||
|
'txt_backup_recommend_group_s3',
|
||||||
|
'txt_backup_destination_name_default_webdav',
|
||||||
|
'txt_backup_destination_name_default_s3',
|
||||||
|
'txt_dash',
|
||||||
|
'txt_text_3',
|
||||||
|
]);
|
||||||
|
const intentionallyEnglishPrefixes = [
|
||||||
|
'txt_log_action_',
|
||||||
|
'txt_log_meta_',
|
||||||
|
'txt_log_reason_',
|
||||||
|
'txt_log_target_type_',
|
||||||
|
'txt_log_trigger_',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isIntentionallyEnglishKey(key) {
|
||||||
|
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [locale, table] of Object.entries(locales)) {
|
||||||
|
const keys = Object.keys(table).sort();
|
||||||
|
const missing = baseKeys.filter((key) => !(key in table));
|
||||||
|
const extra = keys.filter((key) => !baseKeys.includes(key));
|
||||||
|
if (missing.length || extra.length) {
|
||||||
|
errors.push({ locale, missing, extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of baseKeys) {
|
||||||
|
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||||
|
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
|
||||||
|
if (basePlaceholders !== localePlaceholders) {
|
||||||
|
errors.push({ locale, key, basePlaceholders, localePlaceholders });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
|
||||||
|
if (sameAsEnglish.length > 40) {
|
||||||
|
errors.push({
|
||||||
|
locale,
|
||||||
|
sameAsEnglishCount: sameAsEnglish.length,
|
||||||
|
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
|
||||||
|
errors,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const distDir = path.resolve(__dirname, '..', 'dist');
|
||||||
|
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts';
|
||||||
|
import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts';
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserRow(apiKey) {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user@example.com',
|
||||||
|
name: 'User',
|
||||||
|
master_password_hint: null,
|
||||||
|
master_password_hash: 'master-proof',
|
||||||
|
key: 'wrapped-user-key',
|
||||||
|
private_key: null,
|
||||||
|
public_key: null,
|
||||||
|
kdf_type: 0,
|
||||||
|
kdf_iterations: 600000,
|
||||||
|
kdf_memory: null,
|
||||||
|
kdf_parallelism: null,
|
||||||
|
security_stamp: 'security-stamp-original',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
verify_devices: 0,
|
||||||
|
totp_secret: null,
|
||||||
|
totp_recovery_code: null,
|
||||||
|
yubikey_key1: null,
|
||||||
|
yubikey_key2: null,
|
||||||
|
yubikey_key3: null,
|
||||||
|
yubikey_key4: null,
|
||||||
|
yubikey_key5: null,
|
||||||
|
yubikey_nfc: 0,
|
||||||
|
api_key: apiKey,
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDb(apiKey) {
|
||||||
|
const state = {
|
||||||
|
user: createUserRow(apiKey),
|
||||||
|
userWrites: 0,
|
||||||
|
refreshDeletes: 0,
|
||||||
|
auditActions: [],
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
prepare(sql) {
|
||||||
|
let bindings = [];
|
||||||
|
const statement = {
|
||||||
|
bind(...values) {
|
||||||
|
bindings = values;
|
||||||
|
return statement;
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
if (/INSERT INTO users\(/i.test(sql)) {
|
||||||
|
state.userWrites += 1;
|
||||||
|
state.user.security_stamp = bindings[12];
|
||||||
|
state.user.api_key = bindings[24];
|
||||||
|
state.user.updated_at = bindings[26];
|
||||||
|
}
|
||||||
|
if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1;
|
||||||
|
if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]);
|
||||||
|
return { meta: { changes: 1 } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return statement;
|
||||||
|
},
|
||||||
|
async batch(statements) {
|
||||||
|
return statements.map(() => ({ success: true, meta: { changes: 1 } }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function request() {
|
||||||
|
return new Request('https://nodewarden.example/api/accounts/api-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash: 'master-proof' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function env(db) {
|
||||||
|
return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = createDb('ExistingReadableApiKey1234567');
|
||||||
|
const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1');
|
||||||
|
const viewBody = await viewResponse.json();
|
||||||
|
assert(viewResponse.status === 200, 'Viewing an existing readable API key failed');
|
||||||
|
assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key');
|
||||||
|
assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user');
|
||||||
|
assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens');
|
||||||
|
assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing');
|
||||||
|
|
||||||
|
const rotate = createDb('ExistingReadableApiKey1234567');
|
||||||
|
const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1');
|
||||||
|
const rotateBody = await rotateResponse.json();
|
||||||
|
assert(rotateResponse.status === 200, 'API key rotation failed');
|
||||||
|
assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key');
|
||||||
|
assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key');
|
||||||
|
assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp');
|
||||||
|
assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens');
|
||||||
|
assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates');
|
||||||
|
assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate');
|
||||||
|
|
||||||
|
const legacyPlain = 'LegacyHashedApiKey123456789';
|
||||||
|
const legacy = createDb(await hashApiKey(legacyPlain));
|
||||||
|
const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1');
|
||||||
|
assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation');
|
||||||
|
assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated');
|
||||||
|
assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating');
|
||||||
|
|
||||||
|
const missing = createDb(null);
|
||||||
|
const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1');
|
||||||
|
const missingBody = await missingResponse.json();
|
||||||
|
assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized');
|
||||||
|
assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted');
|
||||||
|
|
||||||
|
console.log('Bitwarden-compatible API key view and rotation semantics: PASS');
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
import {
|
||||||
|
buildBackupArchive,
|
||||||
|
parseBackupArchive,
|
||||||
|
validateBackupPayloadContents,
|
||||||
|
} from '../src/services/backup-archive.ts';
|
||||||
|
import { importBackupArchiveBytes } from '../src/services/backup-import.ts';
|
||||||
|
|
||||||
|
const forbiddenRuntimeTables = [
|
||||||
|
'devices',
|
||||||
|
'refresh_tokens',
|
||||||
|
'auth_requests',
|
||||||
|
'trusted_two_factor_device_tokens',
|
||||||
|
'account_passkey_challenges',
|
||||||
|
'used_attachment_download_tokens',
|
||||||
|
];
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlTouchesTable(sql, table) {
|
||||||
|
return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBackupDb(extra = {}) {
|
||||||
|
return {
|
||||||
|
config: [],
|
||||||
|
users: [],
|
||||||
|
domain_settings: [],
|
||||||
|
user_revisions: [],
|
||||||
|
folders: [],
|
||||||
|
ciphers: [],
|
||||||
|
attachments: [],
|
||||||
|
webauthn_credentials: [],
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveBytes(db, tableCounts = {}) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return zipSync({
|
||||||
|
'manifest.json': encoder.encode(JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
exportedAt: new Date(0).toISOString(),
|
||||||
|
appVersion: 'test',
|
||||||
|
storageKind: null,
|
||||||
|
tableCounts,
|
||||||
|
includes: { attachments: false },
|
||||||
|
blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 },
|
||||||
|
attachmentBlobs: [],
|
||||||
|
})),
|
||||||
|
'db.json': encoder.encode(JSON.stringify(db)),
|
||||||
|
}, { level: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createD1Mock({ exportMode = false } = {}) {
|
||||||
|
const preparedSql = [];
|
||||||
|
const db = {
|
||||||
|
prepare(sql) {
|
||||||
|
preparedSql.push(sql);
|
||||||
|
let bindings = [];
|
||||||
|
const statement = {
|
||||||
|
sql,
|
||||||
|
bind(...values) {
|
||||||
|
bindings = values;
|
||||||
|
return statement;
|
||||||
|
},
|
||||||
|
async all() {
|
||||||
|
if (exportMode) return { results: [] };
|
||||||
|
return { results: [] };
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
if (/SELECT sql FROM sqlite_master/i.test(sql)) {
|
||||||
|
const table = String(bindings[0] || '').trim();
|
||||||
|
return { sql: `CREATE TABLE ${table} (id TEXT)` };
|
||||||
|
}
|
||||||
|
if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 };
|
||||||
|
if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
return { meta: { changes: 0 } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return statement;
|
||||||
|
},
|
||||||
|
async batch(statements) {
|
||||||
|
return statements.map(() => ({ success: true, meta: { changes: 0 } }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db, preparedSql };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportMock = createD1Mock({ exportMode: true });
|
||||||
|
const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false });
|
||||||
|
const exportedZip = unzipSync(exported.bytes);
|
||||||
|
const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json']));
|
||||||
|
const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json']));
|
||||||
|
|
||||||
|
for (const table of forbiddenRuntimeTables) {
|
||||||
|
assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`);
|
||||||
|
assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`);
|
||||||
|
assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDb = emptyBackupDb({
|
||||||
|
devices: [{ device_identifier: 'device-secret' }],
|
||||||
|
refresh_tokens: [{ token: 'refresh-secret' }],
|
||||||
|
auth_requests: [{ access_code: 'approval-secret' }],
|
||||||
|
trusted_two_factor_device_tokens: [{ token: 'remember-secret' }],
|
||||||
|
account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }],
|
||||||
|
used_attachment_download_tokens: [{ token_hash: 'download-secret' }],
|
||||||
|
});
|
||||||
|
const legacyArchive = archiveBytes(legacyDb, {
|
||||||
|
devices: 1,
|
||||||
|
refresh_tokens: 1,
|
||||||
|
auth_requests: 1,
|
||||||
|
trusted_two_factor_device_tokens: 1,
|
||||||
|
account_passkey_challenges: 1,
|
||||||
|
used_attachment_download_tokens: 1,
|
||||||
|
});
|
||||||
|
const parsedLegacy = parseBackupArchive(legacyArchive);
|
||||||
|
validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files);
|
||||||
|
for (const table of forbiddenRuntimeTables) {
|
||||||
|
assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreMock = createD1Mock();
|
||||||
|
await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false);
|
||||||
|
for (const table of forbiddenRuntimeTables) {
|
||||||
|
assert(
|
||||||
|
!restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)),
|
||||||
|
`Restore touched forbidden runtime table: ${table}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('backup runtime authentication state exclusion: PASS');
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_REF = 'main';
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||||
|
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||||
|
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||||
|
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||||
|
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--ref' && argv[i + 1]) {
|
||||||
|
args.ref = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg.startsWith('--ref=')) {
|
||||||
|
args.ref = arg.slice('--ref='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawUrl(ref, filePath) {
|
||||||
|
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'NodeWarden global domains sync',
|
||||||
|
Accept: 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnumTypes(source) {
|
||||||
|
const map = new Map();
|
||||||
|
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||||
|
if (!enumMatch) {
|
||||||
|
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||||
|
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||||
|
let match;
|
||||||
|
while ((match = entryRe.exec(body)) !== null) {
|
||||||
|
map.set(match[1], Number(match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.size) {
|
||||||
|
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringList(source) {
|
||||||
|
const domains = [];
|
||||||
|
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = stringRe.exec(source)) !== null) {
|
||||||
|
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return Array.from(new Set(domains.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlobalDomains(source, enumTypes) {
|
||||||
|
const out = [];
|
||||||
|
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||||
|
let match;
|
||||||
|
while ((match = addRe.exec(source)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const type = enumTypes.get(name);
|
||||||
|
if (!Number.isInteger(type)) {
|
||||||
|
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = parseStringList(match[2]);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRulesJson(rules) {
|
||||||
|
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetaJson(meta) {
|
||||||
|
return JSON.stringify(meta, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ref } = parseArgs(process.argv.slice(2));
|
||||||
|
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||||
|
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||||
|
|
||||||
|
const [enumSource, staticStoreSource] = await Promise.all([
|
||||||
|
fetchText(enumUrl),
|
||||||
|
fetchText(staticStoreUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enumTypes = parseEnumTypes(enumSource);
|
||||||
|
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||||
|
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||||
|
const rulesJson = formatRulesJson(rules);
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRules = await readJsonFile(OUT_FILE);
|
||||||
|
const existingMeta = await readJsonFile(META_FILE);
|
||||||
|
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||||
|
const unchangedRef = existingMeta?.ref === ref;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
source: 'https://github.com/bitwarden/server',
|
||||||
|
ref,
|
||||||
|
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||||
|
? existingMeta.generatedAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
rulesCount: rules.length,
|
||||||
|
domainsCount,
|
||||||
|
sourceFiles: [
|
||||||
|
ENUM_PATH,
|
||||||
|
STATIC_STORE_PATH,
|
||||||
|
],
|
||||||
|
sourceUrls: [
|
||||||
|
enumUrl,
|
||||||
|
staticStoreUrl,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||||
|
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { registerAccount } from '../webapp/src/lib/api/auth';
|
||||||
|
import {
|
||||||
|
requireWebCrypto,
|
||||||
|
WebCryptoUnavailableError,
|
||||||
|
} from '../webapp/src/lib/crypto';
|
||||||
|
|
||||||
|
const supportedCrypto = {
|
||||||
|
subtle: {
|
||||||
|
importKey: () => Promise.reject(new Error('not used by capability checks')),
|
||||||
|
},
|
||||||
|
getRandomValues: <T>(array: T): T => array,
|
||||||
|
} as unknown as Crypto;
|
||||||
|
|
||||||
|
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete (globalThis as unknown as Record<string, unknown>)[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Web Crypto guard rejects insecure browser contexts', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
|
||||||
|
WebCryptoUnavailableError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
|
||||||
|
const cryptoWithoutSubtle = {
|
||||||
|
getRandomValues: <T>(array: T): T => array,
|
||||||
|
} as unknown as Crypto;
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
|
||||||
|
WebCryptoUnavailableError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Web Crypto guard accepts a secure supported browser', () => {
|
||||||
|
assert.equal(
|
||||||
|
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
|
||||||
|
supportedCrypto
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registration returns an actionable error without contacting the backend', async () => {
|
||||||
|
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
||||||
|
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
|
||||||
|
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
|
||||||
|
let fetchCalled = false;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
|
||||||
|
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
|
||||||
|
Object.defineProperty(globalThis, 'fetch', {
|
||||||
|
configurable: true,
|
||||||
|
value: async () => {
|
||||||
|
fetchCalled = true;
|
||||||
|
return new Response(null, { status: 500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await registerAccount({
|
||||||
|
email: 'first@example.test',
|
||||||
|
name: 'First Admin',
|
||||||
|
password: 'correct horse battery staple',
|
||||||
|
fallbackIterations: 600_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: false,
|
||||||
|
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
|
||||||
|
});
|
||||||
|
assert.equal(fetchCalled, false);
|
||||||
|
} finally {
|
||||||
|
restoreGlobalProperty('crypto', cryptoDescriptor);
|
||||||
|
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
|
||||||
|
restoreGlobalProperty('fetch', fetchDescriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { Env } from '../src/types';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
|
||||||
|
import { applyCors, handleCors } from '../src/utils/response';
|
||||||
|
|
||||||
|
const env = {} as Env;
|
||||||
|
|
||||||
|
test('only the iframe connector drops anti-framing headers', () => {
|
||||||
|
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
|
||||||
|
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
|
||||||
|
assert.equal(connector.headers.get('X-Frame-Options'), null);
|
||||||
|
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
|
||||||
|
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
|
||||||
|
|
||||||
|
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
|
||||||
|
const request = new Request(`https://vault.example.test${path}`);
|
||||||
|
const response = applyCors(request, new Response('<!doctype html>'), env);
|
||||||
|
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
|
||||||
|
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official Bitwarden desktop origin receives credentialed CORS', () => {
|
||||||
|
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
|
||||||
|
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
Origin: 'bw-desktop-file://bundle',
|
||||||
|
'Access-Control-Request-Headers': 'authorization, content-type',
|
||||||
|
},
|
||||||
|
}), env);
|
||||||
|
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
|
||||||
|
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Worker assets preserve exact official connector .html paths', async () => {
|
||||||
|
for (const configUrl of [
|
||||||
|
new URL('../wrangler.toml', import.meta.url),
|
||||||
|
new URL('../wrangler.kv.toml', import.meta.url),
|
||||||
|
]) {
|
||||||
|
const config = await readFile(configUrl, 'utf8');
|
||||||
|
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
|
||||||
|
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCredentialData,
|
||||||
|
normalizePublicKeyOptions,
|
||||||
|
parseConnectorRequest,
|
||||||
|
resolveParentChannel,
|
||||||
|
} from '../webapp/public/webauthn-connector.js';
|
||||||
|
|
||||||
|
function encodeBase64Utf8(value) {
|
||||||
|
return Buffer.from(value, 'utf8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyOptions = {
|
||||||
|
challenge: 'AQID',
|
||||||
|
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
|
||||||
|
timeout: 60000,
|
||||||
|
rpId: 'vault.example.test',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('parses the official desktop/browser V1 connector request', () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
|
||||||
|
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
|
||||||
|
btnText: encodeURIComponent('Read security key'),
|
||||||
|
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
|
||||||
|
v: '1',
|
||||||
|
});
|
||||||
|
const request = parseConnectorRequest(params);
|
||||||
|
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
|
||||||
|
assert.equal(request.parentProtocol, 'file:');
|
||||||
|
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
|
||||||
|
assert.equal(request.buttonText, 'Read security key');
|
||||||
|
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
|
||||||
|
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
|
||||||
|
v: '2',
|
||||||
|
});
|
||||||
|
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
|
||||||
|
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
|
||||||
|
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
|
||||||
|
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
|
||||||
|
const output = JSON.parse(buildCredentialData({
|
||||||
|
id: 'credential-id',
|
||||||
|
rawId: Uint8Array.from([1, 2, 3]).buffer,
|
||||||
|
type: 'public-key',
|
||||||
|
getClientExtensionResults: () => ({ appid: false }),
|
||||||
|
response: {
|
||||||
|
authenticatorData: Uint8Array.from([4, 5]).buffer,
|
||||||
|
clientDataJSON: Uint8Array.from([6, 7]).buffer,
|
||||||
|
signature: Uint8Array.from([8, 9]).buffer,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.deepEqual(output, {
|
||||||
|
id: 'credential-id',
|
||||||
|
rawId: 'AQID',
|
||||||
|
type: 'public-key',
|
||||||
|
extensions: { appid: false },
|
||||||
|
response: {
|
||||||
|
authenticatorData: 'BAU',
|
||||||
|
clientDataJson: 'Bgc',
|
||||||
|
signature: 'CAk',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts legacy file and current official desktop parent origins', () => {
|
||||||
|
assert.deepEqual(resolveParentChannel({
|
||||||
|
parentProtocol: 'file:',
|
||||||
|
parentUrl: 'file:///C:/Bitwarden/index.html',
|
||||||
|
}, 'https://vault.example.test'), {
|
||||||
|
eventOrigin: 'null',
|
||||||
|
targetOrigin: 'file:///C:/Bitwarden/index.html',
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveParentChannel({
|
||||||
|
parentProtocol: 'bw-desktop-file:',
|
||||||
|
parentUrl: 'bw-desktop-file://bundle/index.html',
|
||||||
|
}, 'https://vault.example.test'), {
|
||||||
|
eventOrigin: 'bw-desktop-file://bundle',
|
||||||
|
targetOrigin: 'bw-desktop-file://bundle/index.html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts configured official extension origins and rejects arbitrary parents', () => {
|
||||||
|
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
|
||||||
|
assert.deepEqual(resolveParentChannel({
|
||||||
|
parentProtocol: 'chrome-extension:',
|
||||||
|
parentUrl: `${extension}/popup/index.html`,
|
||||||
|
}, 'https://vault.example.test', [extension]), {
|
||||||
|
eventOrigin: extension,
|
||||||
|
targetOrigin: extension,
|
||||||
|
});
|
||||||
|
assert.throws(() => resolveParentChannel({
|
||||||
|
parentProtocol: 'https:',
|
||||||
|
parentUrl: 'https://attacker.example/frame',
|
||||||
|
}, 'https://vault.example.test', []), /Untrusted parent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
|
||||||
|
const [html, source, viteConfig] = await Promise.all([
|
||||||
|
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
|
||||||
|
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
|
||||||
|
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
|
||||||
|
]);
|
||||||
|
assert.match(html, /id="webauthn-button"/);
|
||||||
|
assert.match(html, /min-height:\s*40px/);
|
||||||
|
assert.match(html, /background:\s*#2563eb/);
|
||||||
|
assert.match(source, /post\('info\|ready'\)/);
|
||||||
|
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
|
||||||
|
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
|
||||||
|
assert.match(source, /event\.data === 'stop'/);
|
||||||
|
assert.match(source, /event\.data === 'start'/);
|
||||||
|
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
base64UrlFromBuffer,
|
||||||
|
buildCallbackUrl,
|
||||||
|
buildCredentialData,
|
||||||
|
decodeBase64Utf8,
|
||||||
|
normalizePublicKeyOptions,
|
||||||
|
parseConnectorRequest,
|
||||||
|
resolveMobileCallbackUri,
|
||||||
|
} from '../webapp/public/webauthn-mobile-connector.js';
|
||||||
|
|
||||||
|
function encodeBase64Utf8(value) {
|
||||||
|
return Buffer.from(value, 'utf8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function v2Search(payload, extra = '') {
|
||||||
|
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertionOptions = {
|
||||||
|
challenge: 'AQID-v8',
|
||||||
|
rpId: 'vault.example.com',
|
||||||
|
timeout: 60000,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('parses the current Bitwarden Android V2 connector payload', () => {
|
||||||
|
const request = parseConnectorRequest(v2Search({
|
||||||
|
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
|
||||||
|
headerText: 'Verify your identity', mobile: true,
|
||||||
|
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
|
||||||
|
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
assert.equal(request.headerText, 'Verify your identity');
|
||||||
|
assert.equal(request.buttonText, 'Authenticate');
|
||||||
|
assert.equal(request.returnButtonText, 'Return to app');
|
||||||
|
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses callbackUri only as a signal and never as the redirect target', () => {
|
||||||
|
const trustedLooking = parseConnectorRequest(v2Search({
|
||||||
|
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
|
||||||
|
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||||
|
const attacker = parseConnectorRequest(v2Search({
|
||||||
|
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
|
||||||
|
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
|
||||||
|
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
|
||||||
|
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
|
||||||
|
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
|
||||||
|
const payload = { mobile: true, data: assertionOptions };
|
||||||
|
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
|
||||||
|
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
|
||||||
|
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
|
||||||
|
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
|
||||||
|
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
|
||||||
|
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
|
||||||
|
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
|
||||||
|
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
|
||||||
|
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
|
||||||
|
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
|
||||||
|
const original = structuredClone(assertionOptions);
|
||||||
|
const normalized = normalizePublicKeyOptions(original);
|
||||||
|
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
|
||||||
|
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
|
||||||
|
assert.deepEqual(original, assertionOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
|
||||||
|
const serialized = JSON.parse(buildCredentialData({
|
||||||
|
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
|
||||||
|
getClientExtensionResults: () => ({ appid: false }),
|
||||||
|
response: {
|
||||||
|
authenticatorData: Uint8Array.from([3, 4]).buffer,
|
||||||
|
clientDataJSON: Uint8Array.from([5, 6]).buffer,
|
||||||
|
signature: Uint8Array.from([7, 8]).buffer,
|
||||||
|
userHandle: Uint8Array.from([9, 10]).buffer,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
assert.deepEqual(serialized, {
|
||||||
|
id: 'credential-id',
|
||||||
|
rawId: 'AQL_',
|
||||||
|
type: 'public-key',
|
||||||
|
extensions: { appid: false },
|
||||||
|
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
|
||||||
|
});
|
||||||
|
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes success and error callbacks safely', () => {
|
||||||
|
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
|
||||||
|
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTML matches the fallback connector visual structure', async () => {
|
||||||
|
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
|
||||||
|
assert.match(html, /id="webauthn-header"/);
|
||||||
|
assert.match(html, /id="webauthn-button"/);
|
||||||
|
assert.match(html, /class="connector-card"/);
|
||||||
|
assert.match(html, /class="brand"/);
|
||||||
|
assert.match(html, /class="form"/);
|
||||||
|
assert.match(html, /class="msg"/);
|
||||||
|
assert.match(html, /src="\/nodewarden-logo\.svg"/);
|
||||||
|
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
|
||||||
|
assert.match(html, /default-src 'none'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
|
||||||
|
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /window\.location\.replace\(uri\)/);
|
||||||
|
assert.doesNotMatch(source, /location\.assign/);
|
||||||
|
assert.doesNotMatch(source, /safeCallbackFromPayload/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
|
||||||
|
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
|
||||||
|
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
|
||||||
|
assert.match(config, /connectorNavigation\(request\)/);
|
||||||
|
assert.match(config, /WebAuthn connector is unavailable while offline/);
|
||||||
|
});
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.2';
|
export const APP_VERSION = '1.8.0';
|
||||||
|
|||||||
+22
-10
@@ -1,15 +1,26 @@
|
|||||||
|
// Shared backup settings types used by both Worker and webapp code.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// Keep this file serializable and provider-neutral. Runtime state is operational
|
||||||
|
// metadata; destination fields can contain provider credentials and must be
|
||||||
|
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
|
||||||
|
// User-facing provider names should use canonical values here. Legacy aliases
|
||||||
|
// belong in backend normalization, not in this shared type.
|
||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_E3_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';
|
||||||
|
|
||||||
export type BackupDestinationType = 'e3' | 'webdav';
|
export type BackupDestinationType = 's3' | 'webdav';
|
||||||
|
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||||
|
|
||||||
export interface E3BackupDestination {
|
export interface S3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
|
addressingStyle: S3BackupAddressingStyle;
|
||||||
region: string;
|
region: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
@@ -24,7 +35,7 @@ export interface WebDavBackupDestination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type BackupDestinationConfig =
|
export type BackupDestinationConfig =
|
||||||
| E3BackupDestination
|
| S3BackupDestination
|
||||||
| WebDavBackupDestination;
|
| WebDavBackupDestination;
|
||||||
|
|
||||||
export interface BackupRuntimeState {
|
export interface BackupRuntimeState {
|
||||||
@@ -91,26 +102,27 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
||||||
if (type === 'e3') {
|
if (type === 's3') {
|
||||||
return {
|
return {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
bucket: '',
|
bucket: '',
|
||||||
region: BACKUP_DEFAULT_E3_REGION,
|
addressingStyle: 'path-style',
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
if (type === 'e3') return `E3 ${index}`;
|
if (type === 's3') return `S3 ${index}`;
|
||||||
return `WebDAV ${index}`;
|
return `WebDAV ${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||||
|
'ac.cn',
|
||||||
|
'com.cn',
|
||||||
|
'edu.cn',
|
||||||
|
'gov.cn',
|
||||||
|
'net.cn',
|
||||||
|
'org.cn',
|
||||||
|
'ah.cn',
|
||||||
|
'bj.cn',
|
||||||
|
'cq.cn',
|
||||||
|
'fj.cn',
|
||||||
|
'gd.cn',
|
||||||
|
'gs.cn',
|
||||||
|
'gx.cn',
|
||||||
|
'gz.cn',
|
||||||
|
'ha.cn',
|
||||||
|
'hb.cn',
|
||||||
|
'he.cn',
|
||||||
|
'hi.cn',
|
||||||
|
'hk.cn',
|
||||||
|
'hl.cn',
|
||||||
|
'hn.cn',
|
||||||
|
'jl.cn',
|
||||||
|
'js.cn',
|
||||||
|
'jx.cn',
|
||||||
|
'ln.cn',
|
||||||
|
'mo.cn',
|
||||||
|
'nm.cn',
|
||||||
|
'nx.cn',
|
||||||
|
'qh.cn',
|
||||||
|
'sc.cn',
|
||||||
|
'sd.cn',
|
||||||
|
'sh.cn',
|
||||||
|
'sn.cn',
|
||||||
|
'sx.cn',
|
||||||
|
'tj.cn',
|
||||||
|
'tw.cn',
|
||||||
|
'xj.cn',
|
||||||
|
'xz.cn',
|
||||||
|
'yn.cn',
|
||||||
|
'zj.cn',
|
||||||
|
'co.uk',
|
||||||
|
'org.uk',
|
||||||
|
'net.uk',
|
||||||
|
'ac.uk',
|
||||||
|
'gov.uk',
|
||||||
|
'com.au',
|
||||||
|
'net.au',
|
||||||
|
'org.au',
|
||||||
|
'edu.au',
|
||||||
|
'gov.au',
|
||||||
|
'co.nz',
|
||||||
|
'org.nz',
|
||||||
|
'net.nz',
|
||||||
|
'com.br',
|
||||||
|
'com.mx',
|
||||||
|
'com.ar',
|
||||||
|
'com.tr',
|
||||||
|
'com.sg',
|
||||||
|
'com.my',
|
||||||
|
'com.hk',
|
||||||
|
'com.tw',
|
||||||
|
'co.jp',
|
||||||
|
'ne.jp',
|
||||||
|
'or.jp',
|
||||||
|
'co.kr',
|
||||||
|
'or.kr',
|
||||||
|
'co.in',
|
||||||
|
'firm.in',
|
||||||
|
'net.in',
|
||||||
|
'org.in',
|
||||||
|
'co.id',
|
||||||
|
'or.id',
|
||||||
|
'web.id',
|
||||||
|
'co.il',
|
||||||
|
'org.il',
|
||||||
|
'co.za',
|
||||||
|
'com.sa',
|
||||||
|
'com.ph',
|
||||||
|
'com.vn',
|
||||||
|
'com.pk',
|
||||||
|
'com.bd',
|
||||||
|
'com.ng',
|
||||||
|
'github.io',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'cloudflareaccess.com',
|
||||||
|
'vercel.app',
|
||||||
|
'netlify.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'fly.dev',
|
||||||
|
'railway.app',
|
||||||
|
'render.com',
|
||||||
|
'onrender.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractHost(input: string): string {
|
||||||
|
let raw = input.trim().toLowerCase();
|
||||||
|
if (!raw) return '';
|
||||||
|
raw = raw.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
raw = parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||||
|
const atIndex = raw.lastIndexOf('@');
|
||||||
|
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||||
|
if (raw.startsWith('[')) return '';
|
||||||
|
const colonIndex = raw.lastIndexOf(':');
|
||||||
|
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.replace(/^\*+\./, '')
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHost(host: string): boolean {
|
||||||
|
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||||
|
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||||
|
return host.split('.').every((label) => (
|
||||||
|
label.length > 0
|
||||||
|
&& label.length <= 63
|
||||||
|
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomain(value: unknown): string {
|
||||||
|
const host = extractHost(String(value || ''));
|
||||||
|
if (!isValidHost(host)) return '';
|
||||||
|
|
||||||
|
const labels = host.split('.');
|
||||||
|
for (let index = 0; index < labels.length; index += 1) {
|
||||||
|
const suffix = labels.slice(index).join('.');
|
||||||
|
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||||
|
if (index === 0) return '';
|
||||||
|
return labels.slice(index - 1).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||||
|
return !!normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { LIMITS } from './config/limits';
|
||||||
|
|
||||||
|
function buildIconServiceTemplate(origin: string): string {
|
||||||
|
return `${origin}/icons/{}/icon.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceCsp(origin: string): string {
|
||||||
|
return `img-src 'self' data: ${origin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConfigResponse(origin: string) {
|
||||||
|
const fillAssistBase = `${origin}/fill-assist/`;
|
||||||
|
return {
|
||||||
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
|
gitHash: 'nodewarden',
|
||||||
|
server: null,
|
||||||
|
environment: {
|
||||||
|
cloudRegion: 'self-hosted',
|
||||||
|
vault: origin,
|
||||||
|
api: origin + '/api',
|
||||||
|
identity: origin + '/identity',
|
||||||
|
notifications: origin + '/notifications',
|
||||||
|
icons: origin,
|
||||||
|
sso: '',
|
||||||
|
fillAssistRules: fillAssistBase,
|
||||||
|
},
|
||||||
|
push: {
|
||||||
|
pushTechnology: 0,
|
||||||
|
vapidPublicKey: null,
|
||||||
|
},
|
||||||
|
communication: null,
|
||||||
|
settings: {
|
||||||
|
disableUserRegistration: false,
|
||||||
|
suppressOnboardingInterstitials: false,
|
||||||
|
},
|
||||||
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
|
featureStates: {
|
||||||
|
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||||
|
'desktop-ui-settings-dialog': true,
|
||||||
|
'duo-redirect': true,
|
||||||
|
'email-verification': true,
|
||||||
|
'fill-assist-targeting-rules': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
|
'pm-19148-innovation-archive': true,
|
||||||
|
'pm-4516-devices-add-last-activity-date': true,
|
||||||
|
'pm-30529-webauthn-related-origins': true,
|
||||||
|
'unauth-ui-refresh': true,
|
||||||
|
'web-push': false,
|
||||||
|
},
|
||||||
|
object: 'config',
|
||||||
|
};
|
||||||
|
}
|
||||||
+38
-7
@@ -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: 30 * 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: 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,
|
||||||
@@ -24,6 +26,9 @@
|
|||||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
|
// clientSecret length
|
||||||
|
// clientSecret 长度
|
||||||
|
clientSecretLength: 30,
|
||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
// Max failed login attempts before temporary lock.
|
// Max failed login attempts before temporary lock.
|
||||||
@@ -41,6 +46,9 @@
|
|||||||
// Public read-only request budget per IP per minute.
|
// Public read-only request budget per IP per minute.
|
||||||
// 公开只读接口每 IP 每分钟请求配额。
|
// 公开只读接口每 IP 每分钟请求配额。
|
||||||
publicReadRequestsPerMinute: 120,
|
publicReadRequestsPerMinute: 120,
|
||||||
|
// Public website icon proxy budget per IP per minute.
|
||||||
|
// 公开网站图标代理每 IP 每分钟请求配额。
|
||||||
|
publicIconRequestsPerMinute: 500,
|
||||||
// Sensitive public/auth request budget per IP per minute.
|
// Sensitive public/auth request budget per IP per minute.
|
||||||
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||||
sensitivePublicRequestsPerMinute: 30,
|
sensitivePublicRequestsPerMinute: 30,
|
||||||
@@ -56,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,
|
||||||
@@ -130,6 +144,9 @@
|
|||||||
// Max total items (folders + ciphers) allowed in a single import.
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
importItemLimit: 5000,
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||||
@@ -139,6 +156,20 @@
|
|||||||
compatibility: {
|
compatibility: {
|
||||||
// Single source of truth for /config.version and /api/version.
|
// Single source of truth for /config.version and /api/version.
|
||||||
// /config.version 与 /api/version 的统一版本号来源。
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
bitwardenServerVersion: '2026.1.0',
|
// Vaultwarden 1.37.0 advertises 2026.6.0 after aligning its API response
|
||||||
|
// with the response contract required by Bitwarden 2026.7.x clients.
|
||||||
|
bitwardenServerVersion: '2026.6.0',
|
||||||
|
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||||
|
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||||
|
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||||
|
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
|
||||||
|
cipherKeyEncryptionFeatureEnabled: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import type { BackupDestinationRecord } from '../services/backup-config';
|
||||||
|
import {
|
||||||
|
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||||
|
requireBackupDestination,
|
||||||
|
hasBackupSlotBetween,
|
||||||
|
isBackupDueNow,
|
||||||
|
loadBackupSettings,
|
||||||
|
} from '../services/backup-config';
|
||||||
|
import {
|
||||||
|
createRemoteBackupTransferSession,
|
||||||
|
downloadRemoteBackupFile,
|
||||||
|
ensureRemoteRestoreCandidate,
|
||||||
|
} from '../services/backup-uploader';
|
||||||
|
import { getBlobObject } from '../services/blob-store';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
|
||||||
|
import {
|
||||||
|
executeConfiguredBackup,
|
||||||
|
importAndAuditRemoteBackupFile,
|
||||||
|
} from '../handlers/backup';
|
||||||
|
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
|
||||||
|
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||||
|
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
|
||||||
|
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
|
||||||
|
|
||||||
|
interface BackupJobState {
|
||||||
|
token: string;
|
||||||
|
reason: string;
|
||||||
|
acquiredAt: string;
|
||||||
|
touchedAt: string;
|
||||||
|
expiresAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteAttachmentChunkRequest {
|
||||||
|
destination: BackupDestinationRecord;
|
||||||
|
attachments: Array<{
|
||||||
|
blobName: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteAttachmentDownloadRequest {
|
||||||
|
destination: BackupDestinationRecord;
|
||||||
|
blobName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteAttachmentBatchDownloadRequest {
|
||||||
|
destination: BackupDestinationRecord;
|
||||||
|
blobNames?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredBackupRunRequest {
|
||||||
|
actorUserId?: string | null;
|
||||||
|
auditMetadata?: Record<string, unknown> | null;
|
||||||
|
destinationId?: string | null;
|
||||||
|
targetDeviceIdentifier?: string | null;
|
||||||
|
trigger?: 'manual' | 'scheduled';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteBackupRestoreRequest {
|
||||||
|
actorUserId?: string | null;
|
||||||
|
allowChecksumMismatch?: boolean;
|
||||||
|
auditMetadata?: Record<string, unknown> | null;
|
||||||
|
destinationId?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
targetDeviceIdentifier?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(message: string, status: number = 400): Response {
|
||||||
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupTransferRunner {
|
||||||
|
private lastHeartbeatAt = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: DurableObjectState,
|
||||||
|
private readonly env: Env
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private async acquireJob(reason: string): Promise<string | null> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||||
|
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const nowIso = new Date(nowMs).toISOString();
|
||||||
|
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||||
|
token,
|
||||||
|
reason,
|
||||||
|
acquiredAt: nowIso,
|
||||||
|
touchedAt: nowIso,
|
||||||
|
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||||
|
});
|
||||||
|
this.lastHeartbeatAt = 0;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async touchJob(token: string): Promise<void> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
|
||||||
|
this.lastHeartbeatAt = nowMs;
|
||||||
|
|
||||||
|
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||||
|
if (current?.token !== token) return;
|
||||||
|
|
||||||
|
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||||
|
...current,
|
||||||
|
touchedAt: new Date(nowMs).toISOString(),
|
||||||
|
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseJob(token: string): Promise<void> {
|
||||||
|
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||||
|
if (current?.token === token) {
|
||||||
|
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runConfiguredBackup(request: Request): Promise<Response> {
|
||||||
|
let body: ConfiguredBackupRunRequest;
|
||||||
|
try {
|
||||||
|
body = await request.json<ConfiguredBackupRunRequest>();
|
||||||
|
} catch {
|
||||||
|
return badRequest('Backup run payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
|
||||||
|
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||||
|
if (trigger === 'manual' && !actorUserId) {
|
||||||
|
return badRequest('Manual backup run requires an actor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
|
||||||
|
if (!token) {
|
||||||
|
return badRequest('Another backup run is already in progress', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.touchJob(token);
|
||||||
|
const storage = new StorageService(this.env.DB);
|
||||||
|
const progress = actorUserId
|
||||||
|
? async (event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
this.env,
|
||||||
|
actorUserId,
|
||||||
|
event,
|
||||||
|
String(body.targetDeviceIdentifier || '').trim() || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await executeConfiguredBackup(
|
||||||
|
this.env,
|
||||||
|
storage,
|
||||||
|
actorUserId,
|
||||||
|
trigger,
|
||||||
|
body.destinationId || null,
|
||||||
|
() => this.touchJob(token),
|
||||||
|
progress,
|
||||||
|
body.auditMetadata || null
|
||||||
|
);
|
||||||
|
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
object: 'backup-runner-result',
|
||||||
|
result,
|
||||||
|
settings,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
|
} finally {
|
||||||
|
await this.releaseJob(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runScheduledBackups(): Promise<Response> {
|
||||||
|
const token = await this.acquireJob('scheduled');
|
||||||
|
if (!token) {
|
||||||
|
return badRequest('Another backup run is already in progress', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||||
|
try {
|
||||||
|
await this.touchJob(token);
|
||||||
|
const storage = new StorageService(this.env.DB);
|
||||||
|
let scanStartMs = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
await this.touchJob(token);
|
||||||
|
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||||
|
const now = new Date();
|
||||||
|
const dueDestinations = settings.destinations.filter((destination) =>
|
||||||
|
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||||
|
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dueDestinations.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanStartMs = now.getTime();
|
||||||
|
for (const destination of dueDestinations) {
|
||||||
|
await this.touchJob(token);
|
||||||
|
try {
|
||||||
|
await executeConfiguredBackup(
|
||||||
|
this.env,
|
||||||
|
storage,
|
||||||
|
null,
|
||||||
|
'scheduled',
|
||||||
|
destination.id,
|
||||||
|
() => this.touchJob(token)
|
||||||
|
);
|
||||||
|
completed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
destinationId: destination.id,
|
||||||
|
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
completed,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
|
||||||
|
} finally {
|
||||||
|
await this.releaseJob(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreRemoteBackup(request: Request): Promise<Response> {
|
||||||
|
let body: RemoteBackupRestoreRequest;
|
||||||
|
try {
|
||||||
|
body = await request.json<RemoteBackupRestoreRequest>();
|
||||||
|
} catch {
|
||||||
|
return badRequest('Remote restore payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||||
|
if (!actorUserId) {
|
||||||
|
return badRequest('Remote restore requires an actor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.acquireJob(`restore:${actorUserId}`);
|
||||||
|
if (!token) {
|
||||||
|
return badRequest('Another backup or restore run is already in progress', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.touchJob(token);
|
||||||
|
const storage = new StorageService(this.env.DB);
|
||||||
|
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||||
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
|
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||||
|
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
|
||||||
|
const replaceExisting = !!body.replaceExisting;
|
||||||
|
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
this.env,
|
||||||
|
actorUserId,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_fetch_archive',
|
||||||
|
fileName: restoreFileNameFromPath,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||||
|
replaceExisting,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||||
|
return badRequest('Remote backup file checksum does not match its filename');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await importAndAuditRemoteBackupFile(
|
||||||
|
this.env,
|
||||||
|
storage,
|
||||||
|
actorUserId,
|
||||||
|
remoteFile,
|
||||||
|
destination,
|
||||||
|
path,
|
||||||
|
replaceExisting,
|
||||||
|
!checksumOk,
|
||||||
|
body.auditMetadata || null,
|
||||||
|
targetDeviceIdentifier,
|
||||||
|
() => this.touchJob(token)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(result.result), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
|
||||||
|
} finally {
|
||||||
|
await this.releaseJob(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return badRequest('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/run-configured-backup') {
|
||||||
|
return this.runConfiguredBackup(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/run-scheduled-backups') {
|
||||||
|
return this.runScheduledBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/restore-remote-backup') {
|
||||||
|
return this.restoreRemoteBackup(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/download-remote-attachment') {
|
||||||
|
let body: RemoteAttachmentDownloadRequest;
|
||||||
|
try {
|
||||||
|
body = await request.json<RemoteAttachmentDownloadRequest>();
|
||||||
|
} catch {
|
||||||
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
|
}
|
||||||
|
const blobName = String(body?.blobName || '').trim();
|
||||||
|
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
|
}
|
||||||
|
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||||
|
if (!file) {
|
||||||
|
return badRequest('Remote attachment not found', 404);
|
||||||
|
}
|
||||||
|
return new Response(file.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.contentType || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/download-remote-attachment-batch') {
|
||||||
|
let body: RemoteAttachmentBatchDownloadRequest;
|
||||||
|
try {
|
||||||
|
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
|
||||||
|
} catch {
|
||||||
|
return badRequest('Remote attachment batch download payload is invalid');
|
||||||
|
}
|
||||||
|
const blobNames = Array.from(new Set(
|
||||||
|
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||||
|
.map((blobName) => String(blobName || '').trim())
|
||||||
|
.filter(isSafeBackupAttachmentBlobName)
|
||||||
|
));
|
||||||
|
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||||
|
return badRequest('Remote attachment batch download payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const entries: Array<{ blobName: string; path: string }> = [];
|
||||||
|
const files: Record<string, Uint8Array> = {};
|
||||||
|
for (let i = 0; i < blobNames.length; i += 1) {
|
||||||
|
const blobName = blobNames[i];
|
||||||
|
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||||
|
if (!file) continue;
|
||||||
|
const path = `files/${i}.bin`;
|
||||||
|
entries.push({ blobName, path });
|
||||||
|
files[path] = file.bytes;
|
||||||
|
}
|
||||||
|
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
|
||||||
|
|
||||||
|
return new Response(zipSync(files), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname !== '/internal/upload-attachment-chunk') {
|
||||||
|
return badRequest('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: RemoteAttachmentChunkRequest;
|
||||||
|
try {
|
||||||
|
body = await request.json<RemoteAttachmentChunkRequest>();
|
||||||
|
} catch {
|
||||||
|
return badRequest('Attachment chunk payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body?.destination || !Array.isArray(body.attachments)) {
|
||||||
|
return badRequest('Attachment chunk payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteSession = createRemoteBackupTransferSession(body.destination);
|
||||||
|
let uploaded = 0;
|
||||||
|
|
||||||
|
for (const attachment of body.attachments) {
|
||||||
|
const blobName = String(attachment?.blobName || '').trim();
|
||||||
|
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
|
return badRequest('Attachment chunk payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(this.env, blobName);
|
||||||
|
if (!object) {
|
||||||
|
return badRequest(`Attachment blob missing for ${blobName}`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||||
|
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
|
||||||
|
contentType: object.contentType,
|
||||||
|
});
|
||||||
|
uploaded += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
uploaded,
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+458
-139
@@ -1,16 +1,32 @@
|
|||||||
|
import { DurableObject, waitUntil } from 'cloudflare:workers';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
import { notifyMobilePush } from '../services/push-relay';
|
||||||
|
|
||||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
|
||||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
type HubKind = 'user' | 'anonymous-auth-request';
|
||||||
|
|
||||||
interface ConnectionState {
|
interface WsAttachment {
|
||||||
|
kind: HubKind;
|
||||||
|
userId: string | null;
|
||||||
|
authRequestId: string | null;
|
||||||
handshakeComplete: boolean;
|
handshakeComplete: boolean;
|
||||||
protocol: HubProtocol;
|
protocol: HubProtocol;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
@@ -31,6 +47,12 @@ function encodeUtf8(value: string): Uint8Array {
|
|||||||
return new TextEncoder().encode(value);
|
return new TextEncoder().encode(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||||
|
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
function encodeMsgPackInteger(value: number): Uint8Array {
|
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||||
const normalized = Math.trunc(value);
|
const normalized = Math.trunc(value);
|
||||||
if (normalized >= 0 && normalized <= 0x7f) {
|
if (normalized >= 0 && normalized <= 0x7f) {
|
||||||
@@ -130,11 +152,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
|||||||
function buildSignalRJsonInvocation(
|
function buildSignalRJsonInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null,
|
||||||
|
target: string = 'ReceiveMessage'
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: 1,
|
type: 1,
|
||||||
target: 'ReceiveMessage',
|
target,
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
@@ -145,22 +168,19 @@ function buildSignalRJsonInvocation(
|
|||||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonPing(): string {
|
|
||||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
messagePayload: Record<string, unknown>,
|
messagePayload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null,
|
||||||
|
target: string = 'ReceiveMessage'
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
// [type, headers, invocationId, target, arguments]
|
// [type, headers, invocationId, target, arguments, streamIds]
|
||||||
const encodedPayload = encodeMsgPack([
|
const encodedPayload = encodeMsgPack([
|
||||||
1,
|
1,
|
||||||
{},
|
{},
|
||||||
null,
|
null,
|
||||||
'ReceiveMessage',
|
target,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
@@ -168,28 +188,20 @@ function buildSignalRMessagePackInvocation(
|
|||||||
Payload: messagePayload,
|
Payload: messagePayload,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[],
|
||||||
]);
|
]);
|
||||||
return frameSignalRBinary(encodedPayload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackPing(): Uint8Array {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
return frameSignalRBinary(encodeMsgPack([6]));
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
}
|
super(ctx, env);
|
||||||
|
this.ctx.setWebSocketAutoResponse(
|
||||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
new WebSocketRequestResponsePair(
|
||||||
if (typeof data === 'string') return data;
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
export class NotificationsHub {
|
|
||||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
|
||||||
private userId = '';
|
|
||||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
|
||||||
void this.state;
|
|
||||||
void this.env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
@@ -205,20 +217,36 @@ export class NotificationsHub {
|
|||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
} | null;
|
} | null;
|
||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||||
const contextId = String(body?.contextId || '').trim() || null;
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const rawUpdateType = body?.updateType;
|
||||||
|
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
|
||||||
|
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
const payload = body?.payload && typeof body.payload === 'object'
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
? body.payload
|
? body.payload
|
||||||
: {
|
: {
|
||||||
UserId: this.userId,
|
UserId: userId,
|
||||||
Date: revisionDate,
|
Date: revisionDate,
|
||||||
};
|
};
|
||||||
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
userId?: string;
|
||||||
|
authRequestId?: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
} | null;
|
||||||
|
const userId = String(body?.userId || '').trim();
|
||||||
|
const authRequestId = String(body?.authRequestId || '').trim();
|
||||||
|
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
|
||||||
|
|
||||||
|
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
||||||
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -228,7 +256,7 @@ export class NotificationsHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname !== '/notifications/hub') {
|
if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,46 +266,34 @@ export class NotificationsHub {
|
|||||||
|
|
||||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
if (requestUserId) {
|
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
|
||||||
this.userId = requestUserId;
|
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.userId) {
|
if (!isAnonymousAuthRequestHub && !requestUserId) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pair = new WebSocketPair();
|
const pair = new WebSocketPair();
|
||||||
const client = pair[0];
|
const client = pair[0];
|
||||||
const server = pair[1];
|
const server = pair[1];
|
||||||
server.accept();
|
|
||||||
|
|
||||||
this.connections.set(server, {
|
const tags: string[] = [];
|
||||||
|
if (requestDeviceIdentifier) {
|
||||||
|
tags.push(`device:${requestDeviceIdentifier}`);
|
||||||
|
}
|
||||||
|
this.ctx.acceptWebSocket(server, tags);
|
||||||
|
|
||||||
|
server.serializeAttachment({
|
||||||
|
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
|
||||||
|
userId: isAnonymousAuthRequestHub ? null : requestUserId,
|
||||||
|
authRequestId: requestAuthRequestId,
|
||||||
handshakeComplete: false,
|
handshakeComplete: false,
|
||||||
protocol: 'messagepack',
|
protocol: 'messagepack',
|
||||||
deviceIdentifier: requestDeviceIdentifier,
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
});
|
} satisfies WsAttachment);
|
||||||
this.ensurePingLoop();
|
|
||||||
|
|
||||||
server.addEventListener('message', (event) => {
|
|
||||||
void this.handleSocketMessage(server, event.data);
|
|
||||||
});
|
|
||||||
server.addEventListener('close', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
});
|
|
||||||
server.addEventListener('error', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
try {
|
|
||||||
server.close(1011, 'Socket error');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 101,
|
status: 101,
|
||||||
@@ -285,21 +301,20 @@ export class NotificationsHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||||
const connection = this.connections.get(socket);
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
if (!connection) return;
|
if (!attachment) return;
|
||||||
|
|
||||||
if (!connection.handshakeComplete) {
|
if (!attachment.handshakeComplete) {
|
||||||
const text = decodeIncomingMessage(rawData);
|
const text = decodeIncomingMessage(message);
|
||||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
try {
|
try {
|
||||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
connection.protocol = protocol;
|
attachment.handshakeComplete = true;
|
||||||
connection.handshakeComplete = true;
|
ws.serializeAttachment(attachment);
|
||||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
this.broadcastDeviceStatus();
|
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed pre-handshake payloads.
|
// Ignore malformed pre-handshake payloads.
|
||||||
@@ -307,53 +322,34 @@ export class NotificationsHub {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private ensurePingLoop(): void {
|
if (typeof message !== 'string') {
|
||||||
if (this.pingTimer !== null) return;
|
|
||||||
this.pingTimer = setInterval(() => {
|
|
||||||
this.broadcastPing();
|
|
||||||
}, SIGNALR_PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPingLoopIfIdle(): void {
|
|
||||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
|
||||||
clearInterval(this.pingTimer);
|
|
||||||
this.pingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastPing(): void {
|
|
||||||
if (this.connections.size === 0) {
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
ws.send(message);
|
||||||
socket.send(buildSignalRJsonPing());
|
|
||||||
} else {
|
|
||||||
socket.send(buildSignalRMessagePackPing());
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
// ignore send errors on echo
|
||||||
try {
|
|
||||||
socket.close(1011, 'Ping send failed');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||||
|
void ws;
|
||||||
|
void code;
|
||||||
|
void reason;
|
||||||
|
void wasClean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||||
|
void ws;
|
||||||
|
void error;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOnlineDeviceIdentifiers(): string[] {
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const connection of this.connections.values()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
out.add(connection.deviceIdentifier);
|
if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
|
||||||
|
out.add(attachment.deviceIdentifier);
|
||||||
}
|
}
|
||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
}
|
}
|
||||||
@@ -364,58 +360,325 @@ export class NotificationsHub {
|
|||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null
|
||||||
): void {
|
): void {
|
||||||
if (!this.userId || this.connections.size === 0) return;
|
const sockets = targetDeviceIdentifier
|
||||||
|
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||||
|
: this.ctx.getWebSockets();
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
if (sockets.length === 0) return;
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
for (const ws of sockets) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete) continue;
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
if (attachment.protocol === 'json') {
|
||||||
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
} else {
|
} else {
|
||||||
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
|
||||||
try {
|
try {
|
||||||
socket.close(1011, 'Notification send failed');
|
ws.close(1011, 'Notification send failed');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(): void {
|
private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
|
||||||
this.broadcastMessage(
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
{
|
if (
|
||||||
UserId: this.userId,
|
!attachment?.handshakeComplete ||
|
||||||
Date: new Date().toISOString(),
|
attachment.kind !== 'anonymous-auth-request' ||
|
||||||
},
|
attachment.authRequestId !== authRequestId
|
||||||
null,
|
) {
|
||||||
null
|
continue;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
UserId: userId,
|
||||||
|
Id: authRequestId,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (attachment.protocol === 'json') {
|
||||||
|
ws.send(buildSignalRJsonInvocation(
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||||
|
payload,
|
||||||
|
contextId,
|
||||||
|
'AuthRequestResponseRecieved'
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ws.send(buildSignalRMessagePackInvocation(
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||||
|
payload,
|
||||||
|
contextId,
|
||||||
|
'AuthRequestResponseRecieved'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'Notification send failed');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyUserVaultSync(
|
export function notifyUserVaultSync(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string,
|
revisionDate: string,
|
||||||
contextId?: string | null
|
contextId?: string | null
|
||||||
): Promise<void> {
|
): void {
|
||||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyUserLogout(
|
export function notifyUserCiphersSync(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserCipherDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
organizationId?: string | null;
|
||||||
|
collectionIds?: string[] | null;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.cipherId,
|
||||||
|
OrganizationId: payload.organizationId ?? null,
|
||||||
|
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFolderDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
folderId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.folderId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendCreate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendUpdate(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserSendDelete(
|
||||||
|
env: Env,
|
||||||
|
payload: {
|
||||||
|
userId: string;
|
||||||
|
sendId: string;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
payload.userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
|
||||||
|
payload.revisionDate,
|
||||||
|
payload.contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: payload.userId,
|
||||||
|
Id: payload.sendId,
|
||||||
|
RevisionDate: payload.revisionDate,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserLogout(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
targetDeviceIdentifier?: string | null
|
targetDeviceIdentifier?: string | null
|
||||||
): Promise<void> {
|
): void {
|
||||||
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
|
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||||
@@ -431,13 +694,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function notifyAuthRequestResponse(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
authRequestId: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/auth-request-response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
authRequestId,
|
||||||
|
contextId: contextId || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast auth request response notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserAuthRequest(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
authRequestId: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
|
||||||
|
new Date().toISOString(),
|
||||||
|
contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: userId,
|
||||||
|
Id: authRequestId,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyUserUpdate(
|
async function notifyUserUpdate(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
revisionDate: string,
|
||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null,
|
||||||
|
payloadOverride?: Record<string, unknown> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
@@ -453,12 +762,22 @@ async function notifyUserUpdate(
|
|||||||
contextId: contextId || null,
|
contextId: contextId || null,
|
||||||
updateType,
|
updateType,
|
||||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
payload: {
|
payload: payloadOverride || {
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
Date: revisionDate,
|
Date: revisionDate,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
await notifyMobilePush(env, {
|
||||||
|
userId,
|
||||||
|
updateType,
|
||||||
|
revisionDate,
|
||||||
|
contextId,
|
||||||
|
payload: payloadOverride || {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to broadcast realtime notification:', error);
|
console.error('Failed to broadcast realtime notification:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,793 @@
|
|||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||||
|
import {
|
||||||
|
accountPasskeyCredentialToResponse,
|
||||||
|
accountPasskeyPrfStatus,
|
||||||
|
accountPasskeyTokenTtlMs,
|
||||||
|
buildWebAuthnPrfOption,
|
||||||
|
createAccountPasskeyToken,
|
||||||
|
getAccountPasskeyRpConfig,
|
||||||
|
isSerializedEncString,
|
||||||
|
normalizeAccountPasskeyName,
|
||||||
|
normalizeAuthenticationResponse,
|
||||||
|
normalizeRegistrationResponse,
|
||||||
|
normalizeTransports,
|
||||||
|
sha256Base64Url,
|
||||||
|
toSimpleWebAuthnCredential,
|
||||||
|
userHandleToUserId,
|
||||||
|
userIdToWebAuthnUserId,
|
||||||
|
verifyAccountPasskeyToken,
|
||||||
|
} from '../utils/account-passkeys';
|
||||||
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
|
import { createRecoveryCode } from '../utils/recovery-code';
|
||||||
|
|
||||||
|
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||||
|
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||||
|
|
||||||
|
function parseBodyObject(body: unknown): Record<string, any> {
|
||||||
|
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||||
|
try {
|
||||||
|
return parseBodyObject(await request.json());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyUserSecret(
|
||||||
|
env: Env,
|
||||||
|
user: User,
|
||||||
|
body: Record<string, any>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
|
||||||
|
if (!secret) return false;
|
||||||
|
const storedHash = String(user.masterPasswordHash || '').trim();
|
||||||
|
if (!storedHash) return false;
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
return auth.verifyPassword(secret, storedHash, user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
|
||||||
|
const err = error instanceof Error ? error : null;
|
||||||
|
console.error('Account passkey handler failed', {
|
||||||
|
stage,
|
||||||
|
name: err?.name || typeof error,
|
||||||
|
message: err?.message || String(error),
|
||||||
|
stack: err?.stack,
|
||||||
|
...details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function passkeySetupStageMessage(stage: string): string {
|
||||||
|
if (stage === 'verify_master_password') return 'verifying master password';
|
||||||
|
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
|
||||||
|
if (stage === 'generate_options') return 'generating passkey options';
|
||||||
|
if (stage === 'save_challenge') return 'saving passkey challenge';
|
||||||
|
if (stage === 'create_token') return 'creating passkey challenge token';
|
||||||
|
return 'preparing passkey setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||||
|
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Enabled: credentials.length > 0,
|
||||||
|
enabled: credentials.length > 0,
|
||||||
|
Keys: credentials.map((credential, index) => ({
|
||||||
|
Id: index + 1,
|
||||||
|
id: index + 1,
|
||||||
|
Name: credential.name,
|
||||||
|
name: credential.name,
|
||||||
|
Migrated: false,
|
||||||
|
migrated: false,
|
||||||
|
})),
|
||||||
|
keys: credentials.map((credential, index) => ({
|
||||||
|
Id: index + 1,
|
||||||
|
id: index + 1,
|
||||||
|
Name: credential.name,
|
||||||
|
name: credential.name,
|
||||||
|
Migrated: false,
|
||||||
|
migrated: false,
|
||||||
|
})),
|
||||||
|
Object: 'twoFactorWebAuthn',
|
||||||
|
object: 'twoFactorWebAuthn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
|
||||||
|
if (!response) return null;
|
||||||
|
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||||
|
return String(clientData?.challenge || '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
|
||||||
|
if (!response) return null;
|
||||||
|
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||||
|
return String(clientData?.challenge || '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrfKeySet(body: Record<string, any>): {
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
encryptedPrivateKey: string | null;
|
||||||
|
} {
|
||||||
|
if (!hasCompletePrfKeySet(body)) {
|
||||||
|
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
|
||||||
|
}
|
||||||
|
const encryptedUserKey = String(body.encryptedUserKey).trim();
|
||||||
|
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
|
||||||
|
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
|
||||||
|
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
|
||||||
|
throw new Error('Invalid encrypted key set');
|
||||||
|
}
|
||||||
|
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChallenge(
|
||||||
|
storage: StorageService,
|
||||||
|
scope: AccountPasskeyChallengeScope,
|
||||||
|
challenge: string,
|
||||||
|
userId: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
await storage.saveAccountPasskeyChallenge({
|
||||||
|
challengeHash: await sha256Base64Url(challenge),
|
||||||
|
scope,
|
||||||
|
userId,
|
||||||
|
expiresAt: now + accountPasskeyTokenTtlMs(scope),
|
||||||
|
usedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
allowCredentials: [],
|
||||||
|
userVerification: 'required',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'Authentication', options.challenge, null);
|
||||||
|
const token = await createAccountPasskeyToken(env, {
|
||||||
|
scope: 'Authentication',
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId: null,
|
||||||
|
rpId,
|
||||||
|
});
|
||||||
|
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertAccountPasskeyCredential(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
input: {
|
||||||
|
token: string;
|
||||||
|
deviceResponse: unknown;
|
||||||
|
scope: 'Authentication' | 'UpdateKeySet';
|
||||||
|
expectedUserId?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
|
||||||
|
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Passkey challenge token is invalid or expired');
|
||||||
|
}
|
||||||
|
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
|
||||||
|
throw new Error('Passkey challenge token does not match this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = normalizeAuthenticationResponse(input.deviceResponse);
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Invalid passkey assertion response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||||
|
challengeHash,
|
||||||
|
input.scope,
|
||||||
|
payload.userId,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (!consumed) {
|
||||||
|
throw new Error('Passkey challenge has expired or was already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Passkey is not registered for this server');
|
||||||
|
}
|
||||||
|
if (payload.userId && credential.userId !== payload.userId) {
|
||||||
|
throw new Error('Passkey does not belong to this user');
|
||||||
|
}
|
||||||
|
if (credential.purpose !== 'login') {
|
||||||
|
throw new Error('Passkey is not registered for login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||||
|
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||||
|
if (!resolvedUserId || resolvedUserId !== credential.userId) {
|
||||||
|
throw new Error('Passkey user handle does not match this credential');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUserById(resolvedUserId);
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
throw new Error('Passkey user is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: payload.challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: payload.rpId,
|
||||||
|
credential: toSimpleWebAuthnCredential(credential),
|
||||||
|
requireUserVerification: true,
|
||||||
|
advancedFIDOConfig: { userVerification: 'required' },
|
||||||
|
});
|
||||||
|
if (!verification.verified || !verification.authenticationInfo.userVerified) {
|
||||||
|
throw new Error('Passkey assertion could not be verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateAccountPasskeyCounter(
|
||||||
|
credential.userId,
|
||||||
|
credential.credentialId,
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
credential.counter = verification.authenticationInfo.newCounter;
|
||||||
|
return { user, credential };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||||
|
return jsonResponse({
|
||||||
|
data: credentials.map(accountPasskeyCredentialToResponse),
|
||||||
|
Data: credentials.map(accountPasskeyCredentialToResponse),
|
||||||
|
object: 'list',
|
||||||
|
Object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
ContinuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
user: User
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||||
|
if (!credentials.length) return null;
|
||||||
|
|
||||||
|
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
allowCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
userVerification: 'discouraged',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||||
|
return options as unknown as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertTwoFactorPasskeyCredential(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
user: User,
|
||||||
|
deviceResponse: unknown
|
||||||
|
): Promise<AccountPasskeyCredential> {
|
||||||
|
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Invalid passkey assertion response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||||
|
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||||
|
throw new Error('Passkey is not registered for two-step login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const challenge = readAuthenticationChallenge(response);
|
||||||
|
if (!challenge) {
|
||||||
|
throw new Error('Passkey assertion challenge is missing');
|
||||||
|
}
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||||
|
await sha256Base64Url(challenge),
|
||||||
|
'TwoFactorAuthentication',
|
||||||
|
user.id,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (!consumed) {
|
||||||
|
throw new Error('Passkey challenge has expired or was already used');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
credential: toSimpleWebAuthnCredential(credential),
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
if (!verification.verified) {
|
||||||
|
throw new Error('Passkey assertion could not be verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateAccountPasskeyCounter(
|
||||||
|
credential.userId,
|
||||||
|
credential.credentialId,
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
credential.counter = verification.authenticationInfo.newCounter;
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
rpName,
|
||||||
|
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name || user.email,
|
||||||
|
attestationType: 'none',
|
||||||
|
timeout: 60000,
|
||||||
|
excludeCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'discouraged',
|
||||||
|
requireResidentKey: false,
|
||||||
|
userVerification: 'discouraged',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||||
|
return jsonResponse(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||||
|
if (!registrationResponse) {
|
||||||
|
return errorResponse('Invalid passkey registration response', 400);
|
||||||
|
}
|
||||||
|
const challenge = readRegistrationChallenge(registrationResponse);
|
||||||
|
if (!challenge) {
|
||||||
|
return errorResponse('Passkey challenge is missing', 400);
|
||||||
|
}
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||||
|
await sha256Base64Url(challenge),
|
||||||
|
'TwoFactorCreate',
|
||||||
|
userId,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
if (!consumed) {
|
||||||
|
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: registrationResponse,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
requireUserPresence: true,
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
if (!verification.verified) {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||||
|
if (existing) {
|
||||||
|
return errorResponse('Passkey is already registered', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||||
|
await storage.saveAccountPasskeyCredential({
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
purpose: 'twoFactor',
|
||||||
|
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||||
|
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||||
|
credentialId: verification.registrationInfo.credential.id,
|
||||||
|
counter: verification.registrationInfo.credential.counter,
|
||||||
|
type: verification.registrationInfo.credentialType || 'public-key',
|
||||||
|
aaGuid: verification.registrationInfo.aaguid || null,
|
||||||
|
transports,
|
||||||
|
encryptedUserKey: null,
|
||||||
|
encryptedPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
supportsPrf: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
user.updatedAt = now;
|
||||||
|
await storage.saveUser(user);
|
||||||
|
}
|
||||||
|
await storage.deleteRefreshTokensByUserId(userId);
|
||||||
|
AuthService.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.webauthn_2fa.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: null,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedId = Number(body.id ?? body.Id);
|
||||||
|
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||||
|
return errorResponse('Invalid key id', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||||
|
if (credentials.length < 2) {
|
||||||
|
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
}
|
||||||
|
const credential = credentials[requestedId - 1];
|
||||||
|
if (!credential) {
|
||||||
|
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||||
|
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||||
|
await storage.deleteRefreshTokensByUserId(userId);
|
||||||
|
AuthService.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.webauthn_2fa.delete',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: credential.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
let stage = 'verify_master_password';
|
||||||
|
try {
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('Master password verification failed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
stage = 'load_existing_credentials';
|
||||||
|
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||||
|
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum passkey count reached', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
stage = 'generate_options';
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
rpName,
|
||||||
|
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name || user.email,
|
||||||
|
attestationType: 'none',
|
||||||
|
timeout: 60000,
|
||||||
|
excludeCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'required',
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: 'required',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(options as any).extensions = {
|
||||||
|
...((options as any).extensions || {}),
|
||||||
|
prf: {},
|
||||||
|
};
|
||||||
|
stage = 'save_challenge';
|
||||||
|
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
|
||||||
|
stage = 'create_token';
|
||||||
|
const token = await createAccountPasskeyToken(env, {
|
||||||
|
scope: 'CreateCredential',
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId,
|
||||||
|
rpId,
|
||||||
|
});
|
||||||
|
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
|
||||||
|
} catch (error) {
|
||||||
|
logAccountPasskeyHandlerError(stage, error, { userId });
|
||||||
|
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('Master password verification failed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
|
||||||
|
const requestedId = String(body.credentialId || body.id || '').trim();
|
||||||
|
if (requestedId) {
|
||||||
|
credentials = credentials.filter((credential) => credential.id === requestedId);
|
||||||
|
if (!credentials.length) return errorResponse('Account passkey not found', 404);
|
||||||
|
}
|
||||||
|
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
|
||||||
|
|
||||||
|
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
allowCredentials: credentials.map((credential) => ({
|
||||||
|
id: credential.credentialId,
|
||||||
|
transports: (credential.transports || undefined) as any,
|
||||||
|
})),
|
||||||
|
userVerification: 'required',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
|
||||||
|
const token = await createAccountPasskeyToken(env, {
|
||||||
|
scope: 'UpdateKeySet',
|
||||||
|
challenge: options.challenge,
|
||||||
|
userId,
|
||||||
|
rpId,
|
||||||
|
});
|
||||||
|
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
|
||||||
|
if (!payload || payload.userId !== userId) {
|
||||||
|
return errorResponse('Passkey challenge token is invalid or expired', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeHash = await sha256Base64Url(payload.challenge);
|
||||||
|
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
|
||||||
|
if (!consumed) {
|
||||||
|
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
|
||||||
|
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
|
||||||
|
return errorResponse('Maximum passkey count reached', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||||
|
try {
|
||||||
|
prfKeySet = readPrfKeySet(body);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||||
|
if (!registrationResponse) {
|
||||||
|
return errorResponse('Invalid passkey registration response', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { origins } = getAccountPasskeyRpConfig(request, env);
|
||||||
|
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: registrationResponse,
|
||||||
|
expectedChallenge: payload.challenge,
|
||||||
|
expectedOrigin: origins,
|
||||||
|
expectedRPID: payload.rpId,
|
||||||
|
requireUserPresence: true,
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
if (!verification.verified) {
|
||||||
|
return errorResponse('Passkey registration could not be verified', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||||
|
if (existing) {
|
||||||
|
return errorResponse('Passkey is already registered', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
|
||||||
|
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||||
|
const credential: AccountPasskeyCredential = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
purpose: 'login',
|
||||||
|
name: normalizeAccountPasskeyName(body.name),
|
||||||
|
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||||
|
credentialId: verification.registrationInfo.credential.id,
|
||||||
|
counter: verification.registrationInfo.credential.counter,
|
||||||
|
type: verification.registrationInfo.credentialType || 'public-key',
|
||||||
|
aaGuid: verification.registrationInfo.aaguid || null,
|
||||||
|
transports,
|
||||||
|
encryptedUserKey: prfKeySet.encryptedUserKey,
|
||||||
|
encryptedPublicKey: prfKeySet.encryptedPublicKey,
|
||||||
|
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
|
||||||
|
supportsPrf,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveAccountPasskeyCredential(credential);
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.passkey.create',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: credential.id,
|
||||||
|
metadata: {
|
||||||
|
prfStatus: accountPasskeyPrfStatus(credential),
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(accountPasskeyCredentialToResponse(credential));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
let prfKeySet: ReturnType<typeof readPrfKeySet>;
|
||||||
|
try {
|
||||||
|
prfKeySet = readPrfKeySet(body);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid encrypted passkey key set', 400);
|
||||||
|
}
|
||||||
|
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
|
||||||
|
return errorResponse('Encrypted passkey key set is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
|
||||||
|
try {
|
||||||
|
assertion = await assertAccountPasskeyCredential(request, env, storage, {
|
||||||
|
token: String(body.token || ''),
|
||||||
|
deviceResponse: body.deviceResponse,
|
||||||
|
scope: 'UpdateKeySet',
|
||||||
|
expectedUserId: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateAccountPasskeyEncryption(
|
||||||
|
userId,
|
||||||
|
assertion.credential.credentialId,
|
||||||
|
prfKeySet.encryptedUserKey,
|
||||||
|
prfKeySet.encryptedPublicKey,
|
||||||
|
prfKeySet.encryptedPrivateKey
|
||||||
|
);
|
||||||
|
if (!updated) return errorResponse('Passkey not found', 404);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.passkey.encryption.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: assertion.credential.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
if (!(await verifyUserSecret(env, user, body))) {
|
||||||
|
return errorResponse('Master password verification failed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
|
||||||
|
if (!deleted) return errorResponse('Passkey not found', 404);
|
||||||
|
|
||||||
|
await safeWriteAuditEvent(env, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'account.passkey.delete',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'accountPasskey',
|
||||||
|
targetId: credentialId,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
return jsonResponse({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
|
||||||
|
return buildWebAuthnPrfOption(credential);
|
||||||
|
}
|
||||||
+965
-95
File diff suppressed because it is too large
Load Diff
+194
-36
@@ -1,13 +1,42 @@
|
|||||||
import { Env, User, Invite } from '../types';
|
import { Env, User, Invite } from '../types';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
|
||||||
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||||
|
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
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('');
|
||||||
@@ -24,16 +53,20 @@ async function writeAuditLog(
|
|||||||
action: string,
|
action: string,
|
||||||
targetType: string | null,
|
targetType: string | null,
|
||||||
targetId: string | null,
|
targetId: string | null,
|
||||||
metadata: Record<string, unknown> | null
|
metadata: Record<string, unknown> | null,
|
||||||
|
request?: Request
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await storage.createAuditLog({
|
await writeAuditEvent(storage, {
|
||||||
id: generateUUID(),
|
|
||||||
actorUserId,
|
actorUserId,
|
||||||
action,
|
action,
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
category: action.startsWith('admin.user.') ? 'security' : 'system',
|
||||||
createdAt: new Date().toISOString(),
|
level: action.startsWith('admin.user.') ? 'security' : 'info',
|
||||||
|
metadata: {
|
||||||
|
...(metadata || {}),
|
||||||
|
...(request ? auditRequestMetadata(request) : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,23 +97,130 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/logs
|
||||||
|
export async function handleAdminListAuditLogs(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
||||||
|
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
|
||||||
|
const category = String(url.searchParams.get('category') || '').trim() || null;
|
||||||
|
const level = String(url.searchParams.get('level') || '').trim() || null;
|
||||||
|
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
|
||||||
|
const from = String(url.searchParams.get('from') || '').trim() || null;
|
||||||
|
const to = String(url.searchParams.get('to') || '').trim() || null;
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
|
||||||
|
return jsonResponse({
|
||||||
|
data: result.logs.map(log => ({
|
||||||
|
id: log.id,
|
||||||
|
actorUserId: log.actorUserId,
|
||||||
|
actorEmail: log.actorEmail,
|
||||||
|
action: log.action,
|
||||||
|
category: log.category,
|
||||||
|
level: log.level,
|
||||||
|
targetType: log.targetType,
|
||||||
|
targetId: log.targetId,
|
||||||
|
targetUserEmail: log.targetUserEmail,
|
||||||
|
metadata: log.metadata,
|
||||||
|
createdAt: log.createdAt,
|
||||||
|
object: 'auditLog',
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: result.hasMore,
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/admin/logs/settings
|
||||||
|
export async function handleAdminGetAuditLogSettings(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'auditLogSettings',
|
||||||
|
...await getAuditLogSettings(storage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/logs/settings
|
||||||
|
export async function handleAdminUpdateAuditLogSettings(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'auditLogSettings',
|
||||||
|
...settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/admin/logs
|
||||||
|
export async function handleAdminClearAuditLogs(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const deleted = await storage.clearAuditLogs();
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||||
|
deleted,
|
||||||
|
}, request);
|
||||||
|
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/admin/invites
|
// POST /api/admin/invites
|
||||||
export async function handleAdminCreateInvite(
|
export async function handleAdminCreateInvite(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -92,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();
|
||||||
@@ -115,9 +252,9 @@ export async function handleAdminCreateInvite(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.createInvite(invite);
|
await storage.createInvite(invite);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
|
||||||
expiresInHours,
|
expiresInHours,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse(toInviteResponse(request, invite), 201);
|
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||||
}
|
}
|
||||||
@@ -144,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,
|
||||||
@@ -154,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', code, null);
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
|
||||||
|
code,
|
||||||
|
}, request);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,16 +313,29 @@ 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,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse({ deleted }, 200);
|
return jsonResponse({ deleted }, 200);
|
||||||
}
|
}
|
||||||
@@ -195,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) {
|
||||||
@@ -222,9 +375,10 @@ export async function handleAdminSetUserStatus(
|
|||||||
if (nextStatus === 'banned') {
|
if (nextStatus === 'banned') {
|
||||||
await storage.deleteRefreshTokensByUserId(target.id);
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
}
|
}
|
||||||
|
AuthService.invalidateUserCache(target.id);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: target.id,
|
id: target.id,
|
||||||
@@ -242,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);
|
||||||
}
|
}
|
||||||
@@ -250,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) {
|
||||||
@@ -280,9 +437,10 @@ export async function handleAdminDeleteUser(
|
|||||||
|
|
||||||
await storage.deleteRefreshTokensByUserId(target.id);
|
await storage.deleteRefreshTokensByUserId(target.id);
|
||||||
await storage.deleteUserById(target.id);
|
await storage.deleteUserById(target.id);
|
||||||
|
AuthService.invalidateUserCache(target.id);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||||
email: target.email,
|
targetEmail: target.email,
|
||||||
});
|
}, request);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-50
@@ -1,16 +1,17 @@
|
|||||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Attachment, Cipher } from '../types';
|
||||||
import { 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,
|
||||||
verifyAttachmentUploadToken,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +21,68 @@ import {
|
|||||||
getBlobStorageMaxBytes,
|
getBlobStorageMaxBytes,
|
||||||
putBlobObject,
|
putBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCipherUpdateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserCipherUpdate(env, {
|
||||||
|
userId: cipher.userId,
|
||||||
|
cipherId: cipher.id,
|
||||||
|
revisionDate,
|
||||||
|
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
|
||||||
|
collectionIds: Array.isArray((cipher as any).collectionIds)
|
||||||
|
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
|
||||||
|
: null,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||||
|
const fallback = 'attachment';
|
||||||
|
const value = String(fileName || fallback)
|
||||||
|
.replace(/[\r\n"]/g, '_')
|
||||||
|
.trim() || fallback;
|
||||||
|
return `attachment; filename="${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAttachmentAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'attachment',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
@@ -38,9 +93,22 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const limit = Math.max(1, concurrency);
|
||||||
|
for (let index = 0; index < items.length; index += limit) {
|
||||||
|
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processAttachmentUpload(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
cipher: Cipher,
|
||||||
attachment: Attachment,
|
attachment: Attachment,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
@@ -56,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,
|
||||||
@@ -81,7 +153,8 @@ async function processAttachmentUpload(
|
|||||||
|
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
@@ -98,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);
|
||||||
}
|
}
|
||||||
@@ -136,16 +209,17 @@ 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);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -158,9 +232,7 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 1,
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +248,18 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePublicUploadAttachment(
|
export async function handlePublicUploadAttachment(
|
||||||
@@ -215,17 +287,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return processAttachmentUpload(request, env, attachment, cipherId);
|
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
@@ -240,16 +312,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||||
|
|
||||||
// Generate short-lived download token
|
// Generate short-lived download token
|
||||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||||
@@ -260,8 +333,67 @@ export async function handleGetAttachment(
|
|||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'attachment',
|
object: 'attachment',
|
||||||
id: attachment.id,
|
id: responseAttachment.id,
|
||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
|
fileName: responseAttachment.fileName,
|
||||||
|
key: responseAttachment.key,
|
||||||
|
size: String(Number(responseAttachment.size) || 0),
|
||||||
|
sizeName: responseAttachment.sizeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
|
||||||
|
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
|
||||||
|
export async function handleUpdateAttachmentMetadata(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { fileName?: string | null; key?: string | null };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||||
|
return errorResponse('No metadata fields supplied', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
|
||||||
|
const fileName = String(body.fileName || '').trim();
|
||||||
|
if (!fileName) return errorResponse('fileName is required', 400);
|
||||||
|
attachment.fileName = fileName;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
|
||||||
|
const key = body.key == null ? null : String(body.key || '').trim();
|
||||||
|
attachment.key = key || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'attachment',
|
||||||
|
id: attachment.id,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
key: attachment.key,
|
key: attachment.key,
|
||||||
size: String(Number(attachment.size) || 0),
|
size: String(Number(attachment.size) || 0),
|
||||||
@@ -277,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');
|
||||||
@@ -290,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);
|
||||||
}
|
}
|
||||||
@@ -309,22 +439,23 @@ 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),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -341,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);
|
||||||
}
|
}
|
||||||
@@ -356,25 +487,30 @@ 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);
|
||||||
|
|
||||||
// Remove attachment from cipher
|
|
||||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
|
||||||
|
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||||
|
id: attachmentId,
|
||||||
|
cipherId,
|
||||||
|
size: attachment.size,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// 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);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
Cipher: cipherResponse,
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
cipher: cipherResponse,
|
||||||
}),
|
Object: 'deleteAttachment',
|
||||||
|
object: 'deleteAttachment',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,12 +519,24 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
env: Env,
|
env: Env,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
await deleteAllAttachmentsForCiphers(env, [cipherId]);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
}
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
export async function deleteAllAttachmentsForCiphers(
|
||||||
|
env: Env,
|
||||||
|
cipherIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
|
||||||
|
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
|
||||||
|
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
|
||||||
|
);
|
||||||
|
if (!attachments.length) return;
|
||||||
|
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
});
|
||||||
}
|
|
||||||
|
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
|
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
||||||
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
|
||||||
|
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||||
|
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
|
||||||
|
|
||||||
|
function normalizeText(value: unknown, maxLength: number): string {
|
||||||
|
return String(value ?? '').trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSerializedEncString(value: unknown): value is string {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return false;
|
||||||
|
const parts = text.split('.');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const type = Number(parts[0]);
|
||||||
|
const bodyParts = parts[1].split('|');
|
||||||
|
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||||
|
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||||
|
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(request: Request): string | null {
|
||||||
|
return (
|
||||||
|
request.headers.get('CF-Connecting-IP') ||
|
||||||
|
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountryName(request: Request): string | null {
|
||||||
|
return request.headers.get('CF-IPCountry') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceTypeName(type: number): string {
|
||||||
|
const names: Record<number, string> = {
|
||||||
|
0: 'Android',
|
||||||
|
1: 'iOS',
|
||||||
|
2: 'Chrome Extension',
|
||||||
|
3: 'Firefox Extension',
|
||||||
|
4: 'Opera Extension',
|
||||||
|
5: 'Edge Extension',
|
||||||
|
6: 'Windows Desktop',
|
||||||
|
7: 'macOS Desktop',
|
||||||
|
8: 'Linux Desktop',
|
||||||
|
9: 'Chrome',
|
||||||
|
10: 'Firefox',
|
||||||
|
11: 'Opera',
|
||||||
|
12: 'Edge',
|
||||||
|
13: 'Internet Explorer',
|
||||||
|
14: 'Unknown Browser',
|
||||||
|
15: 'Android',
|
||||||
|
16: 'Windows UWP',
|
||||||
|
17: 'Safari',
|
||||||
|
18: 'Vivaldi',
|
||||||
|
19: 'Vivaldi Extension',
|
||||||
|
20: 'Safari Extension',
|
||||||
|
21: 'SDK',
|
||||||
|
22: 'Server',
|
||||||
|
23: 'Windows CLI',
|
||||||
|
24: 'macOS CLI',
|
||||||
|
25: 'Linux CLI',
|
||||||
|
26: 'DuckDuckGo',
|
||||||
|
};
|
||||||
|
return names[type] || `Device ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrigin(request: Request): string {
|
||||||
|
return new URL(request.url).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
|
||||||
|
return {
|
||||||
|
id: authRequest.id,
|
||||||
|
Id: authRequest.id,
|
||||||
|
publicKey: authRequest.publicKey,
|
||||||
|
PublicKey: authRequest.publicKey,
|
||||||
|
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||||
|
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||||
|
requestDeviceTypeValue: authRequest.requestDeviceType,
|
||||||
|
RequestDeviceTypeValue: authRequest.requestDeviceType,
|
||||||
|
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||||
|
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||||
|
requestIpAddress: authRequest.requestIpAddress,
|
||||||
|
RequestIpAddress: authRequest.requestIpAddress,
|
||||||
|
requestCountryName: authRequest.requestCountryName,
|
||||||
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
|
key: authRequest.key,
|
||||||
|
Key: authRequest.key,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
MasterPasswordHash: null,
|
||||||
|
creationDate: authRequest.creationDate,
|
||||||
|
CreationDate: authRequest.creationDate,
|
||||||
|
responseDate: authRequest.responseDate,
|
||||||
|
ResponseDate: authRequest.responseDate,
|
||||||
|
requestApproved: authRequest.approved ?? false,
|
||||||
|
RequestApproved: authRequest.approved ?? false,
|
||||||
|
requestDeviceId: requestDeviceId ?? null,
|
||||||
|
RequestDeviceId: requestDeviceId ?? null,
|
||||||
|
origin: buildOrigin(request),
|
||||||
|
Origin: buildOrigin(request),
|
||||||
|
object: 'auth-request',
|
||||||
|
Object: 'auth-request',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listResponse<T>(data: T[]) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
Data: data,
|
||||||
|
object: 'list',
|
||||||
|
Object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
ContinuationToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
return body && typeof body === 'object' ? body as Record<string, any> : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceAuthRequestCreateRateLimit(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
email: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
|
||||||
|
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
|
||||||
|
const encodedEmail = encodeURIComponent(email || 'missing');
|
||||||
|
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
|
||||||
|
const budgets = await Promise.all([
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
|
||||||
|
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
|
||||||
|
]);
|
||||||
|
const blocked = budgets.find((budget) => !budget.allowed);
|
||||||
|
if (!blocked) return null;
|
||||||
|
|
||||||
|
return errorResponse('Too many authentication requests. Try again later.', 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||||
|
for (const name of names) {
|
||||||
|
if (body[name] !== undefined) return body[name];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
|
||||||
|
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAuthRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase();
|
||||||
|
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||||
|
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||||
|
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||||
|
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(
|
||||||
|
{
|
||||||
|
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||||
|
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||||
|
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||||
|
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
||||||
|
}
|
||||||
|
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||||
|
if (rateLimitResponse) return rateLimitResponse;
|
||||||
|
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||||
|
return errorResponse('Invalid auth request type.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
return errorResponse('User or known device not found.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.pruneExpiredAuthRequests();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const authRequest: AuthRequestRecord = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: null,
|
||||||
|
type,
|
||||||
|
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
requestDeviceType: deviceInfo.deviceType,
|
||||||
|
requestIpAddress: getClientIp(request),
|
||||||
|
requestCountryName: getCountryName(request),
|
||||||
|
responseDeviceIdentifier: null,
|
||||||
|
accessCode,
|
||||||
|
publicKey,
|
||||||
|
key: null,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
approved: null,
|
||||||
|
creationDate: now,
|
||||||
|
responseDate: null,
|
||||||
|
authenticationDate: null,
|
||||||
|
};
|
||||||
|
await storage.createAuthRequest(authRequest);
|
||||||
|
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAdminAuthRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
userEmail: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
|
||||||
|
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||||
|
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||||
|
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(
|
||||||
|
{
|
||||||
|
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||||
|
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||||
|
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||||
|
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
|
||||||
|
}
|
||||||
|
if (email !== userEmail.toLowerCase()) {
|
||||||
|
return errorResponse('Email does not match authenticated user.', 400);
|
||||||
|
}
|
||||||
|
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||||
|
return errorResponse('Public key, device identifier, and access code are required.', 400);
|
||||||
|
}
|
||||||
|
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
|
||||||
|
if (rateLimitResponse) return rateLimitResponse;
|
||||||
|
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
return errorResponse('User not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.pruneExpiredAuthRequests();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const authRequest: AuthRequestRecord = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: null,
|
||||||
|
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
|
||||||
|
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
requestDeviceType: deviceInfo.deviceType,
|
||||||
|
requestIpAddress: getClientIp(request),
|
||||||
|
requestCountryName: getCountryName(request),
|
||||||
|
responseDeviceIdentifier: null,
|
||||||
|
accessCode,
|
||||||
|
publicKey,
|
||||||
|
key: null,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
approved: null,
|
||||||
|
creationDate: now,
|
||||||
|
responseDate: null,
|
||||||
|
authenticationDate: null,
|
||||||
|
};
|
||||||
|
await storage.createAuthRequest(authRequest);
|
||||||
|
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const accessCode = normalizeText(url.searchParams.get('code'), 25);
|
||||||
|
const authRequest = await storage.getAuthRequestById(id);
|
||||||
|
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const authRequests = await storage.listAuthRequestsByUserId(userId);
|
||||||
|
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.pruneExpiredAuthRequests();
|
||||||
|
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
|
||||||
|
const rows = await Promise.all(authRequests.map(async (authRequest) => {
|
||||||
|
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
|
||||||
|
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
|
||||||
|
}));
|
||||||
|
return jsonResponse(listResponse(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
|
||||||
|
return errorResponse('Auth request has already been answered.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
|
||||||
|
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
|
||||||
|
if (latestForDevice?.id !== authRequest.id) {
|
||||||
|
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
|
const responseDeviceIdentifier =
|
||||||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
|
readActingDeviceIdentifier(request) ||
|
||||||
|
'web';
|
||||||
|
|
||||||
|
if (approved && !key) {
|
||||||
|
return errorResponse('Encrypted key is required to approve the request.', 400);
|
||||||
|
}
|
||||||
|
if (approved && !isSerializedEncString(key)) {
|
||||||
|
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||||
|
approved,
|
||||||
|
responseDeviceIdentifier,
|
||||||
|
key,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
});
|
||||||
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
|
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||||
|
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||||
|
if (approved) {
|
||||||
|
await notifyAuthRequestResponse(env, userId, id);
|
||||||
|
}
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
|
||||||
|
}
|
||||||
+666
-176
File diff suppressed because it is too large
Load Diff
+907
-149
File diff suppressed because it is too large
Load Diff
+281
-18
@@ -1,11 +1,16 @@
|
|||||||
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||||
import { StorageService } from '../services/storage';
|
import { 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);
|
||||||
|
|
||||||
function normalizeIdentifier(value: string | null | undefined): string {
|
function normalizeIdentifier(value: string | null | undefined): string {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
@@ -23,13 +28,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDeviceResponse(device: Device): DeviceResponse {
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
UserId: device.userId,
|
UserId: device.userId,
|
||||||
userId: device.userId,
|
userId: device.userId,
|
||||||
Name: device.name,
|
Name: displayName,
|
||||||
name: device.name,
|
name: displayName,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -38,6 +48,12 @@ 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,
|
||||||
|
HasStoredDevice: true,
|
||||||
|
hasStoredDevice: true,
|
||||||
IsTrusted: isTrustedDevice(device),
|
IsTrusted: isTrustedDevice(device),
|
||||||
isTrusted: isTrustedDevice(device),
|
isTrusted: isTrustedDevice(device),
|
||||||
EncryptedUserKey: device.encryptedUserKey,
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
@@ -55,8 +71,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
|
|||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
Name: device.name,
|
Name: String(device.deviceNote || '').trim() || device.name,
|
||||||
name: device.name,
|
name: String(device.deviceNote || '').trim() || device.name,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -101,6 +121,89 @@ async function readJsonBody(request: Request): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDeviceName(value: unknown): string {
|
||||||
|
return String(value || '').trim().slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeviceType(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||||
|
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices
|
||||||
|
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
|
||||||
|
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
|
||||||
|
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
|
||||||
|
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
|
||||||
|
|
||||||
|
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
|
||||||
|
if (pushToken) {
|
||||||
|
const device = await storage.getDevice(userId, identifier);
|
||||||
|
const pushUuid = device?.pushUuid || generateUUID();
|
||||||
|
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
|
||||||
|
if (updated) {
|
||||||
|
await registerMobilePushDevice(env, {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: identifier,
|
||||||
|
type,
|
||||||
|
pushUuid,
|
||||||
|
pushToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await storage.getDevice(userId, identifier);
|
||||||
|
if (!device) return errorResponse('Device registration failed', 500);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.register',
|
||||||
|
category: 'device',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: identifier,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/lost-trust
|
||||||
|
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request) || {};
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(
|
||||||
|
{
|
||||||
|
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
|
||||||
|
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
|
||||||
|
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.lost_trust',
|
||||||
|
category: 'device',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: deviceInfo.deviceIdentifier,
|
||||||
|
metadata: {
|
||||||
|
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
deviceType: deviceInfo.deviceType,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/knowndevice
|
// 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
|
||||||
@@ -202,13 +305,18 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
encryptedUserKey: null,
|
encryptedUserKey: null,
|
||||||
encryptedPublicKey: null,
|
encryptedPublicKey: null,
|
||||||
encryptedPrivateKey: null,
|
encryptedPrivateKey: null,
|
||||||
|
pushUuid: null,
|
||||||
|
pushToken: null,
|
||||||
devicePendingAuthRequest: null,
|
devicePendingAuthRequest: null,
|
||||||
|
deviceNote: null,
|
||||||
|
lastSeenAt: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
data.push({
|
data.push({
|
||||||
...buildDeviceResponse(placeholderDevice),
|
...buildDeviceResponse(placeholderDevice),
|
||||||
isTrusted: true,
|
isTrusted: true,
|
||||||
|
hasStoredDevice: false,
|
||||||
online: onlineSet.has(row.deviceIdentifier),
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustedTokenCount: row.tokenCount,
|
trustedTokenCount: row.tokenCount,
|
||||||
@@ -245,9 +353,50 @@ export async function handleRevokeTrustedDevice(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.revoke',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { removed, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removed });
|
return jsonResponse({ success: true, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/authorized/:deviceIdentifier/permanent
|
||||||
|
// Upgrades an existing active 2FA remember-token record to permanent trust.
|
||||||
|
export async function handleTrustDevicePermanently(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
|
||||||
|
if (!updated) return errorResponse('Device is not currently trusted', 409);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.permanent',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { updated, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
updated,
|
||||||
|
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/devices/:deviceIdentifier
|
// DELETE /api/devices/:deviceIdentifier
|
||||||
export async function handleDeleteDevice(
|
export async function handleDeleteDevice(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -260,22 +409,81 @@ export async function handleDeleteDevice(
|
|||||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
await notifyUserLogout(env, userId, normalized);
|
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||||
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.delete',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT /api/devices/:deviceIdentifier/name
|
||||||
|
export async function handleUpdateDeviceName(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const name = parseDeviceName(body?.name);
|
||||||
|
if (!name) return errorResponse('Device name is required', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||||
|
if (!updated) return errorResponse('Device not found', 404);
|
||||||
|
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) return errorResponse('Device not found', 404);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.name.update',
|
||||||
|
category: 'device',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { name, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
// 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),
|
||||||
@@ -284,7 +492,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
|
|||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await notifyUserLogout(env, userId, null);
|
AuthService.invalidateUserCache(userId);
|
||||||
|
notifyUserLogout(env, userId, null);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.delete_all',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +594,15 @@ export async function handleUntrustDevices(
|
|||||||
if (!deviceIdentifier) continue;
|
if (!deviceIdentifier) continue;
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.trust.revoke_batch',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: userId,
|
||||||
|
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: true, removed });
|
return jsonResponse({ success: true, removed });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,28 +638,58 @@ export async function handleDeactivateDevice(
|
|||||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
await notifyUserLogout(env, userId, normalized);
|
await unregisterMobilePushDevice(env, device?.pushUuid);
|
||||||
|
AuthService.invalidateDeviceCache(userId, normalized);
|
||||||
|
notifyUserLogout(env, userId, normalized);
|
||||||
}
|
}
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: 'device.deactivate',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: normalized,
|
||||||
|
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||||
|
});
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
// PUT /api/devices/identifier/{deviceIdentifier}/token
|
||||||
// Bitwarden mobile reports push token updates to this endpoint.
|
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
|
||||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
|
||||||
export async function handleUpdateDeviceToken(
|
export async function handleUpdateDeviceToken(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceIdentifier: string
|
deviceIdentifier: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
void env;
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
void userId;
|
|
||||||
void deviceIdentifier;
|
const body = await readJsonBody(request);
|
||||||
|
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
|
||||||
|
if (!pushToken) return errorResponse('Invalid push token', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) return errorResponse('Device not found', 404);
|
||||||
|
|
||||||
|
const pushUuid = device.pushUuid || generateUUID();
|
||||||
|
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
|
||||||
|
if (updated) {
|
||||||
|
await registerMobilePushDevice(env, {
|
||||||
|
userId,
|
||||||
|
deviceIdentifier: normalized,
|
||||||
|
type: device.type,
|
||||||
|
pushUuid,
|
||||||
|
pushToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,9 +715,15 @@ export async function handleClearDeviceToken(
|
|||||||
deviceIdentifier: string
|
deviceIdentifier: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
void env;
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
void userId;
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
void deviceIdentifier;
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cleared = await storage.clearDevicePushToken(userId, normalized);
|
||||||
|
if (cleared?.pushUuid) {
|
||||||
|
await unregisterMobilePushDevice(env, cleared.pushUuid);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import {
|
||||||
|
buildDomainsResponse,
|
||||||
|
customRulesToActiveEquivalentDomains,
|
||||||
|
normalizeCustomEquivalentDomains,
|
||||||
|
normalizeEquivalentDomains,
|
||||||
|
normalizeExcludedGlobalTypes,
|
||||||
|
} from '../services/domain-rules';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
|
||||||
|
// It stores custom rules, then derives equivalentDomains from the non-excluded
|
||||||
|
// custom rules. Keep this behavior aligned with backup import/export and
|
||||||
|
// src/services/storage-domain-rules-repo.ts.
|
||||||
|
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const parsed = await request.json();
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const payload = await readPayload(request);
|
||||||
|
const current = await storage.getUserDomainSettings(userId);
|
||||||
|
const equivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'equivalentDomains',
|
||||||
|
'EquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'customEquivalentDomains',
|
||||||
|
'CustomEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'excludedGlobalEquivalentDomains',
|
||||||
|
'ExcludedGlobalEquivalentDomains',
|
||||||
|
// Some older compatible clients send the excluded type list under this key.
|
||||||
|
'globalEquivalentDomains',
|
||||||
|
'GlobalEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||||
|
? (equivalentDomainsRaw === undefined
|
||||||
|
? current.customEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||||
|
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||||
|
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||||
|
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||||
|
? current.excludedGlobalEquivalentDomains
|
||||||
|
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||||
|
|
||||||
|
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||||
|
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
return errorResponse('Domain settings unavailable', 500);
|
||||||
|
}
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
|
||||||
|
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
|
||||||
|
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
|
||||||
|
|
||||||
|
const EMPTY_FORMS_BODY = JSON.stringify({
|
||||||
|
schemaVersion: '1.0.0',
|
||||||
|
hosts: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Bitwarden Fill Assist Forms v1',
|
||||||
|
type: 'object',
|
||||||
|
required: ['schemaVersion', 'hosts'],
|
||||||
|
properties: {
|
||||||
|
schemaVersion: { type: 'string' },
|
||||||
|
hosts: { type: 'object' },
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_MANIFEST_BODY = JSON.stringify({
|
||||||
|
buildId: 'nodewarden-empty-fill-assist-v1',
|
||||||
|
timestamp: '2026-07-06T00:00:00.000Z',
|
||||||
|
gitSha: 'nodewarden',
|
||||||
|
maps: {
|
||||||
|
forms: {
|
||||||
|
v1: {
|
||||||
|
filename: EMPTY_FORMS_FILENAME,
|
||||||
|
cid: EMPTY_FORMS_CID,
|
||||||
|
schema: EMPTY_FORMS_SCHEMA_FILENAME,
|
||||||
|
deprecated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
|
||||||
|
linked: false,
|
||||||
|
maxAge: '86400s',
|
||||||
|
debugString: 'No matching digital asset link policy is configured for this server.',
|
||||||
|
});
|
||||||
|
|
||||||
|
function fillAssistJsonResponse(body: string): Response {
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilename(filename: string): string {
|
||||||
|
const raw = String(filename || '').trim();
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFillAssistManifest(): Response {
|
||||||
|
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleFillAssistForms(filename: string): Response {
|
||||||
|
const normalized = normalizeFilename(filename);
|
||||||
|
if (normalized === EMPTY_FORMS_FILENAME) {
|
||||||
|
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
|
||||||
|
}
|
||||||
|
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
|
||||||
|
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
|
||||||
|
}
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDigitalAssetLinkCheck(): Response {
|
||||||
|
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
|
||||||
|
}
|
||||||
+76
-10
@@ -1,18 +1,45 @@
|
|||||||
import { Env, Folder, FolderResponse } from '../types';
|
import { Env, Folder, FolderResponse } from '../types';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import {
|
||||||
|
notifyUserFolderCreate,
|
||||||
|
notifyUserFolderDelete,
|
||||||
|
notifyUserFolderUpdate,
|
||||||
|
notifyUserVaultSync,
|
||||||
|
} from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFolderAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'folder',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
@@ -21,6 +48,7 @@ function folderToResponse(folder: Folder): FolderResponse {
|
|||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
revisionDate: folder.updatedAt,
|
revisionDate: folder.updatedAt,
|
||||||
|
creationDate: folder.createdAt,
|
||||||
object: 'folder',
|
object: 'folder',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,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);
|
||||||
@@ -87,7 +115,13 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderCreate(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
@@ -95,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);
|
||||||
@@ -115,7 +149,13 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderUpdate(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
@@ -123,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);
|
||||||
@@ -132,7 +172,16 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
await storage.clearFolderFromCiphers(userId, id);
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifyUserFolderDelete(env, {
|
||||||
|
userId,
|
||||||
|
folderId: id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -153,9 +202,26 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
|||||||
return errorResponse('Folder ids are required', 400);
|
return errorResponse('Folder ids are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folders = (
|
||||||
|
await Promise.all(ids.map(async (id) => {
|
||||||
|
const folder = await storage.getFolderForUser(id, userId);
|
||||||
|
return folder;
|
||||||
|
}))
|
||||||
|
).filter((folder): folder is Folder => !!folder);
|
||||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
for (const folder of folders) {
|
||||||
|
notifyUserFolderDelete(env, {
|
||||||
|
userId,
|
||||||
|
folderId: folder.id,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||||
|
count: ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
|
|||||||
+753
-92
File diff suppressed because it is too large
Load Diff
+94
-54
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
|
|||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
@@ -17,14 +17,16 @@ 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; match?: number | null }> | null;
|
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
autofillOnPageLoad?: boolean | null;
|
autofillOnPageLoad?: boolean | null;
|
||||||
fido2Credentials?: any[] | null;
|
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
passwordRevisionDate?: string | null;
|
passwordRevisionDate?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -83,6 +85,22 @@ function bindNull(v: any): any {
|
|||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
|
||||||
|
if (!source || typeof source !== 'object') return undefined;
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
return source[key] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
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);
|
||||||
@@ -103,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);
|
||||||
@@ -119,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,
|
||||||
};
|
};
|
||||||
@@ -148,20 +167,34 @@ 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) || 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 card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||||
|
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||||
|
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||||
|
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
|
||||||
|
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
|
||||||
|
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
|
||||||
|
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
|
||||||
|
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||||
|
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...c,
|
...c,
|
||||||
@@ -172,70 +205,77 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
name: c.name ?? 'Untitled',
|
name: c.name ?? 'Untitled',
|
||||||
notes: c.notes ?? null,
|
notes: c.notes ?? null,
|
||||||
favorite: c.favorite ?? false,
|
favorite: c.favorite ?? false,
|
||||||
login: c.login ? {
|
login: login ? {
|
||||||
...c.login,
|
...login,
|
||||||
username: c.login.username ?? null,
|
username: login.username ?? null,
|
||||||
password: c.login.password ?? null,
|
password: login.password ?? null,
|
||||||
uris: c.login.uris?.map(u => ({
|
uris: login.uris?.map((u: any) => ({
|
||||||
...u,
|
...u,
|
||||||
uri: u.uri ?? null,
|
uri: u.uri ?? null,
|
||||||
uriChecksum: null,
|
uriChecksum: u.uriChecksum ?? null,
|
||||||
match: u.match ?? null,
|
match: u.match ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp ?? null,
|
totp: login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
uri: c.login.uri ?? null,
|
uri: login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
card: c.card ? {
|
card: card ? {
|
||||||
...c.card,
|
...card,
|
||||||
cardholderName: c.card.cardholderName ?? null,
|
cardholderName: card.cardholderName ?? null,
|
||||||
brand: c.card.brand ?? null,
|
brand: card.brand ?? null,
|
||||||
number: c.card.number ?? null,
|
number: card.number ?? null,
|
||||||
expMonth: c.card.expMonth ?? null,
|
expMonth: card.expMonth ?? null,
|
||||||
expYear: c.card.expYear ?? null,
|
expYear: card.expYear ?? null,
|
||||||
code: c.card.code ?? null,
|
code: card.code ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
identity: c.identity ? {
|
identity: identity ? {
|
||||||
...c.identity,
|
...identity,
|
||||||
title: c.identity.title ?? null,
|
title: identity.title ?? null,
|
||||||
firstName: c.identity.firstName ?? null,
|
firstName: identity.firstName ?? null,
|
||||||
middleName: c.identity.middleName ?? null,
|
middleName: identity.middleName ?? null,
|
||||||
lastName: c.identity.lastName ?? null,
|
lastName: identity.lastName ?? null,
|
||||||
address1: c.identity.address1 ?? null,
|
address1: identity.address1 ?? null,
|
||||||
address2: c.identity.address2 ?? null,
|
address2: identity.address2 ?? null,
|
||||||
address3: c.identity.address3 ?? null,
|
address3: identity.address3 ?? null,
|
||||||
city: c.identity.city ?? null,
|
city: identity.city ?? null,
|
||||||
state: c.identity.state ?? null,
|
state: identity.state ?? null,
|
||||||
postalCode: c.identity.postalCode ?? null,
|
postalCode: identity.postalCode ?? null,
|
||||||
country: c.identity.country ?? null,
|
country: identity.country ?? null,
|
||||||
company: c.identity.company ?? null,
|
company: identity.company ?? null,
|
||||||
email: c.identity.email ?? null,
|
email: identity.email ?? null,
|
||||||
phone: c.identity.phone ?? null,
|
phone: identity.phone ?? null,
|
||||||
ssn: c.identity.ssn ?? null,
|
ssn: identity.ssn ?? null,
|
||||||
username: c.identity.username ?? null,
|
username: identity.username ?? null,
|
||||||
passportNumber: c.identity.passportNumber ?? null,
|
passportNumber: identity.passportNumber ?? null,
|
||||||
licenseNumber: c.identity.licenseNumber ?? null,
|
licenseNumber: identity.licenseNumber ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
secureNote: c.secureNote ?? null,
|
secureNote: secureNote ?? null,
|
||||||
fields: c.fields?.map(f => ({
|
fields: fields?.map((f: any) => ({
|
||||||
...f,
|
...f,
|
||||||
name: f.name ?? null,
|
name: f.name ?? null,
|
||||||
value: f.value ?? null,
|
value: f.value ?? null,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
linkedId: f.linkedId ?? null,
|
linkedId: f.linkedId ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: c.passwordHistory ?? null,
|
passwordHistory: passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
|
||||||
key: (c as any).key ?? null,
|
bankAccount: bankAccount ?? null,
|
||||||
|
driversLicense: driversLicense ?? null,
|
||||||
|
passport: passport ?? null,
|
||||||
|
key: key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||||
|
if (compatibilityError) {
|
||||||
|
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
cipherRows.push(cipher);
|
cipherRows.push(cipher);
|
||||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
@@ -273,7 +313,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// Update revision date
|
// Update revision date
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
|
||||||
if (returnCipherMap) {
|
if (returnCipherMap) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -56,3 +58,24 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
|
|||||||
}
|
}
|
||||||
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
|
||||||
|
if (!authRequestId) return errorResponse('Token is required', 400);
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return errorResponse('Expected websocket', 426);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const authRequest = await storage.getAuthRequestById(authRequestId);
|
||||||
|
if (!authRequest || isAuthRequestExpired(authRequest)) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const forwardedUrl = new URL(request.url);
|
||||||
|
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
|
||||||
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
import {
|
import {
|
||||||
getBlobStorageMaxBytes,
|
getBlobStorageMaxBytes,
|
||||||
getSendFileObjectKey,
|
getSendFileObjectKey,
|
||||||
|
getBlobObject,
|
||||||
putBlobObject,
|
putBlobObject,
|
||||||
deleteBlobObject,
|
deleteBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
@@ -16,6 +17,9 @@ import {
|
|||||||
formatSize,
|
formatSize,
|
||||||
getAliasedProp,
|
getAliasedProp,
|
||||||
normalizeEmails,
|
normalizeEmails,
|
||||||
|
notifySendCreateForRequest,
|
||||||
|
notifySendDeleteForRequest,
|
||||||
|
notifySendUpdateForRequest,
|
||||||
notifyVaultSyncForRequest,
|
notifyVaultSyncForRequest,
|
||||||
parseDate,
|
parseDate,
|
||||||
parseFileLength,
|
parseFileLength,
|
||||||
@@ -29,6 +33,30 @@ import {
|
|||||||
setSendPassword,
|
setSendPassword,
|
||||||
validateDeletionDate,
|
validateDeletionDate,
|
||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
|
|
||||||
|
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
|
||||||
|
|
||||||
|
async function writeSendAudit(
|
||||||
|
storage: StorageService,
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action,
|
||||||
|
category: 'data',
|
||||||
|
level: action.includes('delete') ? 'security' : 'info',
|
||||||
|
targetType: 'send',
|
||||||
|
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
...auditRequestMetadata(request),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function processSendFileUpload(
|
async function processSendFileUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -57,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: {
|
||||||
@@ -76,7 +109,8 @@ async function processSendFileUpload(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
return new Response(null, { status: 201 });
|
return new Response(null, { status: 201 });
|
||||||
}
|
}
|
||||||
@@ -97,8 +131,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
sends = await storage.getAllSends(userId);
|
sends = await storage.getAllSends(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: sends.map(sendToResponse),
|
data: sendResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken,
|
continuationToken,
|
||||||
});
|
});
|
||||||
@@ -107,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);
|
||||||
@@ -183,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 = {
|
||||||
@@ -225,7 +266,8 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
@@ -306,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 = {
|
||||||
@@ -348,7 +396,8 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
const jwtSecret = getSafeJwtSecret(env);
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
return errorResponse('Server configuration error', 500);
|
return errorResponse('Server configuration error', 500);
|
||||||
@@ -372,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);
|
||||||
}
|
}
|
||||||
@@ -407,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);
|
||||||
}
|
}
|
||||||
@@ -443,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);
|
||||||
}
|
}
|
||||||
@@ -456,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);
|
||||||
}
|
}
|
||||||
@@ -563,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']);
|
||||||
@@ -575,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,15 +648,15 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -618,7 +671,12 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
await storage.deleteSend(sendId, userId);
|
await storage.deleteSend(sendId, userId);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||||
|
id: sendId,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -649,16 +707,22 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
for (const send of sends) {
|
||||||
|
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
}
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||||
|
count: sends.length,
|
||||||
|
requestedCount: body.ids.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -667,15 +731,19 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||||
|
id: send.id,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -685,7 +753,12 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
|||||||
send.updatedAt = new Date().toISOString();
|
send.updatedAt = new Date().toISOString();
|
||||||
await storage.saveSend(send);
|
await storage.saveSend(send);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
|
||||||
|
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||||
|
id: send.id,
|
||||||
|
type: send.type,
|
||||||
|
});
|
||||||
|
|
||||||
return jsonResponse(sendToResponse(send));
|
return jsonResponse(sendToResponse(send));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getSafeJwtSecret,
|
getSafeJwtSecret,
|
||||||
hasEmailAuth,
|
hasEmailAuth,
|
||||||
isSendAvailable,
|
isSendAvailable,
|
||||||
|
notifySendUpdateForRequest,
|
||||||
notifyVaultSyncForRequest,
|
notifyVaultSyncForRequest,
|
||||||
parseStoredSendData,
|
parseStoredSendData,
|
||||||
resolveSendFromIdOrAccessId,
|
resolveSendFromIdOrAccessId,
|
||||||
@@ -33,6 +34,14 @@ import {
|
|||||||
verifySendPasswordHashB64,
|
verifySendPasswordHashB64,
|
||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
|
|
||||||
|
function contentDispositionAttachment(fileName: string | null | undefined): string {
|
||||||
|
const fallback = 'send-file';
|
||||||
|
const value = String(fileName || fallback)
|
||||||
|
.replace(/[\r\n"]/g, '_')
|
||||||
|
.trim() || fallback;
|
||||||
|
return `attachment; filename="${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const sendId = fromAccessId(accessId);
|
const sendId = fromAccessId(accessId);
|
||||||
@@ -59,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) {
|
||||||
@@ -89,7 +98,8 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -102,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);
|
||||||
@@ -133,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) {
|
||||||
@@ -162,7 +171,8 @@ export async function handleAccessSendFile(
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -202,7 +212,8 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
@@ -241,7 +252,8 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
|
|||||||
}
|
}
|
||||||
send.accessCount += 1;
|
send.accessCount += 1;
|
||||||
const revisionDate = await storage.updateRevisionDate(send.userId);
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
|
||||||
|
|
||||||
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -278,9 +290,14 @@ export async function handleDownloadSendFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
const send = await storage.getSend(sendId);
|
||||||
if (!object) {
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
return errorResponse('Send file not found', 404);
|
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);
|
||||||
@@ -288,11 +305,19 @@ export async function handleDownloadSendFile(
|
|||||||
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),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,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) {
|
||||||
@@ -343,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,5 +1,10 @@
|
|||||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import {
|
||||||
|
notifyUserSendCreate,
|
||||||
|
notifyUserSendDelete,
|
||||||
|
notifyUserSendUpdate,
|
||||||
|
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 { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
@@ -9,13 +14,58 @@ export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer availa
|
|||||||
const SEND_PASSWORD_ITERATIONS = 100_000;
|
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||||
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||||
|
|
||||||
export async function notifyVaultSyncForRequest(
|
export function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
revisionDate: string
|
revisionDate: string
|
||||||
): Promise<void> {
|
): void {
|
||||||
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySendCreateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendCreate(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySendUpdateForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendUpdate(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifySendDeleteForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): void {
|
||||||
|
notifyUserSendDelete(env, {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
revisionDate,
|
||||||
|
contextId: readActingDeviceIdentifier(request),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||||
@@ -105,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;
|
||||||
}
|
}
|
||||||
@@ -321,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 };
|
||||||
@@ -384,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 {
|
||||||
@@ -414,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 };
|
||||||
|
|||||||
+93
-143
@@ -1,96 +1,47 @@
|
|||||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
|
||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
buildAccountKeys,
|
|
||||||
buildUserDecryptionCompat,
|
buildUserDecryptionCompat,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
|
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||||
|
import { buildProfileResponse } from '../utils/profile-response';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
// CONTRACT:
|
||||||
userId: string;
|
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||||
revisionDate: string;
|
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||||
body: string;
|
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||||
expiresAt: number;
|
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||||
bytes: number;
|
function buildSyncCacheRequest(
|
||||||
|
request: Request,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string,
|
||||||
|
accountPasskeyCacheTag: string,
|
||||||
|
excludeDomains: boolean,
|
||||||
|
excludeSends: boolean,
|
||||||
|
preserveRepairableUris: boolean
|
||||||
|
): Request {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cacheUrl = new URL(
|
||||||
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
|
||||||
|
url.origin
|
||||||
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
let syncResponseCacheTotalBytes = 0;
|
const hit = await caches.default.match(cacheRequest);
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
|
||||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSyncCache(key: string): string | null {
|
|
||||||
const hit = syncResponseCache.get(key);
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
return new Response(hit.body, hit);
|
||||||
deleteSyncCacheEntry(key, hit);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
const existing = entry ?? syncResponseCache.get(key);
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
if (!existing) return;
|
|
||||||
syncResponseCache.delete(key);
|
|
||||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.expiresAt <= nowMs) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
pruneExpiredSyncCache(nowMs);
|
|
||||||
pruneStaleUserSyncCache(userId, revisionDate);
|
|
||||||
|
|
||||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
|
||||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = syncResponseCache.get(key);
|
|
||||||
if (existing) {
|
|
||||||
deleteSyncCacheEntry(key, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (
|
|
||||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
|
||||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
|
||||||
) {
|
|
||||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
|
||||||
if (!oldestKey) break;
|
|
||||||
deleteSyncCacheEntry(oldestKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncResponseCache.set(key, {
|
|
||||||
userId,
|
|
||||||
revisionDate,
|
|
||||||
body,
|
|
||||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
|
||||||
bytes: bodyBytes,
|
|
||||||
});
|
|
||||||
syncResponseCacheTotalBytes += bodyBytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
@@ -99,106 +50,105 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||||
const omitFido2Credentials =
|
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||||
userAgent.includes('android') ||
|
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios');
|
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const [revisionDate, accountPasskeys] = await Promise.all([
|
||||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
storage.getRevisionDate(userId),
|
||||||
const cachedBody = readSyncCache(cacheKey);
|
storage.getAccountPasskeyCredentialsByUserId(userId),
|
||||||
if (cachedBody) {
|
]);
|
||||||
return new Response(cachedBody, {
|
const accountPasskeyCacheTag = accountPasskeys
|
||||||
status: 200,
|
.map((credential) => [
|
||||||
headers: { 'Content-Type': 'application/json' },
|
credential.id,
|
||||||
});
|
credential.updatedAt,
|
||||||
|
credential.supportsPrf ? '1' : '0',
|
||||||
|
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
|
||||||
|
].join(':'))
|
||||||
|
.join(',');
|
||||||
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
|
||||||
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||||
const folders = await storage.getAllFolders(userId);
|
storage.getAllCiphers(userId),
|
||||||
const sends = await storage.getAllSends(userId);
|
storage.getAllFolders(userId),
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
|
]);
|
||||||
|
const webAuthnPrfOptions = accountPasskeys
|
||||||
|
.map(buildWebAuthnPrfOption)
|
||||||
|
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||||
|
const validFolderIds = new Set(folders.map((folder) => folder.id));
|
||||||
|
|
||||||
// Build profile response
|
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||||
const profile: ProfileResponse = {
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
emailVerified: true,
|
|
||||||
premium: true,
|
|
||||||
premiumFromOrganization: false,
|
|
||||||
usesKeyConnector: false,
|
|
||||||
masterPasswordHint: user.masterPasswordHint,
|
|
||||||
culture: 'en-US',
|
|
||||||
twoFactorEnabled: !!user.totpSecret,
|
|
||||||
key: user.key,
|
|
||||||
privateKey: user.privateKey,
|
|
||||||
accountKeys: buildAccountKeys(user),
|
|
||||||
securityStamp: user.securityStamp || user.id,
|
|
||||||
organizations: [],
|
|
||||||
providers: [],
|
|
||||||
providerOrganizations: [],
|
|
||||||
forcePasswordReset: false,
|
|
||||||
avatarColor: null,
|
|
||||||
creationDate: user.createdAt,
|
|
||||||
verifyDevices: user.verifyDevices,
|
|
||||||
object: 'profile',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build cipher responses with attachments
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
if (isCipherResponseSyncCompatible(response)) {
|
||||||
|
cipherResponses.push(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
|
folderResponses.push({
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
revisionDate: folder.updatedAt,
|
revisionDate: folder.updatedAt,
|
||||||
|
creationDate: folder.createdAt,
|
||||||
object: 'folder',
|
object: 'folder',
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: excludeDomains
|
domains: excludeDomains
|
||||||
? null
|
? null
|
||||||
: {
|
: buildDomainsResponse(
|
||||||
equivalentDomains: [],
|
domainSettings?.equivalentDomains || [],
|
||||||
globalEquivalentDomains: [],
|
domainSettings?.customEquivalentDomains || [],
|
||||||
object: 'domains',
|
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||||
},
|
{ omitExcludedGlobals: true }
|
||||||
|
),
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
policiesNew: [],
|
||||||
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
|
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||||
|
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||||
|
V2UpgradeToken: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
await writeSyncCache(cacheRequest, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-18
@@ -1,34 +1,56 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { NotificationsHub } from './durable/notifications-hub';
|
import { NotificationsHub } from './durable/notifications-hub';
|
||||||
|
import { BackupTransferRunner } from './durable/backup-transfer-runner';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import { applyCors, jsonResponse } from './utils/response';
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
import { runScheduledBackupIfDue } from './handlers/backup';
|
import { runScheduledBackupIfDue } from './handlers/backup';
|
||||||
|
import {
|
||||||
|
isBackendRequestPath,
|
||||||
|
isWebVaultHidden,
|
||||||
|
webVaultNotFoundResponse,
|
||||||
|
} from './web-vault-visibility';
|
||||||
|
|
||||||
let dbInitialized = false;
|
let dbInitialized = false;
|
||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
let dbInitPromise: Promise<void> | null = null;
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
function isWorkerHandledPath(path: string): boolean {
|
function normalizeRequestUrl(request: Request): Request {
|
||||||
return (
|
const url = new URL(request.url);
|
||||||
path.startsWith('/api/') ||
|
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||||
path.startsWith('/identity/') ||
|
if (normalizedPathname === url.pathname) return request;
|
||||||
path.startsWith('/icons/') ||
|
|
||||||
path.startsWith('/notifications/') ||
|
url.pathname = normalizedPathname;
|
||||||
path.startsWith('/.well-known/') ||
|
return new Request(url.toString(), request);
|
||||||
path === '/config' ||
|
}
|
||||||
path === '/api/config' ||
|
|
||||||
path === '/api/version'
|
function addSearchIndexHeaders(request: Request, response: Response): Response {
|
||||||
);
|
const url = new URL(request.url);
|
||||||
|
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
const shouldNoIndex =
|
||||||
|
url.pathname === '/robots.txt' ||
|
||||||
|
contentType.includes('text/html');
|
||||||
|
|
||||||
|
if (!shouldNoIndex) return response;
|
||||||
|
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||||
if (!env.ASSETS) return null;
|
if (!env.ASSETS) return null;
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (isWorkerHandledPath(url.pathname)) return null;
|
if (isBackendRequestPath(url.pathname)) return null;
|
||||||
|
|
||||||
return env.ASSETS.fetch(request);
|
const response = await env.ASSETS.fetch(request);
|
||||||
|
return addSearchIndexHeaders(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
@@ -56,9 +78,16 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
void ctx;
|
void ctx;
|
||||||
const assetResponse = await maybeServeAsset(request, env);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
|
const requestPath = new URL(normalizedRequest.url).pathname;
|
||||||
|
|
||||||
|
if (isWebVaultHidden(env) && !isBackendRequestPath(requestPath)) {
|
||||||
|
return webVaultNotFoundResponse(normalizedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(request, assetResponse);
|
return applyCors(normalizedRequest, assetResponse, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -76,11 +105,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(request, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(request, 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> {
|
||||||
@@ -97,3 +126,4 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { NotificationsHub };
|
export { NotificationsHub };
|
||||||
|
export { BackupTransferRunner };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -4,11 +4,32 @@ import {
|
|||||||
handleAdminCreateInvite,
|
handleAdminCreateInvite,
|
||||||
handleAdminListInvites,
|
handleAdminListInvites,
|
||||||
handleAdminDeleteAllInvites,
|
handleAdminDeleteAllInvites,
|
||||||
handleAdminRevokeInvite,
|
handleAdminDeleteInvite,
|
||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
|
handleAdminListAuditLogs,
|
||||||
|
handleAdminGetAuditLogSettings,
|
||||||
|
handleAdminUpdateAuditLogSettings,
|
||||||
|
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,
|
||||||
@@ -17,10 +38,31 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs' && method === 'GET') {
|
||||||
|
return handleAdminListAuditLogs(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs' && method === 'DELETE') {
|
||||||
|
return handleAdminClearAuditLogs(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/logs/settings') {
|
||||||
|
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||||
if (adminBackupResponse) return adminBackupResponse;
|
if (adminBackupResponse) return adminBackupResponse;
|
||||||
|
|
||||||
@@ -34,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);
|
||||||
|
|||||||
+191
-13
@@ -1,8 +1,9 @@
|
|||||||
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,
|
||||||
|
handleGetKeys,
|
||||||
handleSetKeys,
|
handleSetKeys,
|
||||||
handleGetRevisionDate,
|
handleGetRevisionDate,
|
||||||
handleVerifyPassword,
|
handleVerifyPassword,
|
||||||
@@ -11,6 +12,18 @@ import {
|
|||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetTwoFactorProviders,
|
||||||
|
handleGetTwoFactorAuthenticator,
|
||||||
|
handlePutTwoFactorAuthenticator,
|
||||||
|
handleGetTwoFactorYubiKey,
|
||||||
|
handlePutTwoFactorYubiKey,
|
||||||
|
handlePutTwoFactorYubiKeyConfig,
|
||||||
|
handleBootstrapTwoFactorYubiKeyConfig,
|
||||||
|
handleGetDeviceVerificationSettings,
|
||||||
|
handlePutDeviceVerificationSettings,
|
||||||
|
handleDisableTwoFactorProvider,
|
||||||
|
handleGetApiKey,
|
||||||
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
import {
|
import {
|
||||||
handleGetCiphers,
|
handleGetCiphers,
|
||||||
@@ -58,10 +71,31 @@ import {
|
|||||||
handleCreateAttachment,
|
handleCreateAttachment,
|
||||||
handleUploadAttachment,
|
handleUploadAttachment,
|
||||||
handleGetAttachment,
|
handleGetAttachment,
|
||||||
|
handleUpdateAttachmentMetadata,
|
||||||
handleDeleteAttachment,
|
handleDeleteAttachment,
|
||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||||
|
import {
|
||||||
|
handleCreateAccountPasskeyCredential,
|
||||||
|
handleDeleteAccountPasskeyCredential,
|
||||||
|
handleDeleteTwoFactorWebAuthn,
|
||||||
|
handleGetAccountPasskeyAttestationOptions,
|
||||||
|
handleGetAccountPasskeyCredentials,
|
||||||
|
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||||
|
handleGetTwoFactorWebAuthn,
|
||||||
|
handleGetTwoFactorWebAuthnChallenge,
|
||||||
|
handlePutTwoFactorWebAuthn,
|
||||||
|
handleUpdateAccountPasskeyEncryption,
|
||||||
|
} from './handlers/account-passkeys';
|
||||||
|
import {
|
||||||
|
handleCreateAdminAuthRequest,
|
||||||
|
handleGetAuthRequest,
|
||||||
|
handleListAuthRequests,
|
||||||
|
handleListPendingAuthRequests,
|
||||||
|
handleUpdateAuthRequest,
|
||||||
|
} from './handlers/auth-requests';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -83,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);
|
||||||
@@ -93,8 +161,10 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleChangePassword(request, env, userId);
|
return handleChangePassword(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
if (path === '/api/accounts/keys') {
|
||||||
return handleSetKeys(request, env, userId);
|
if (method === 'GET') return handleGetKeys(request, env, userId);
|
||||||
|
if (method === 'POST') return handleSetKeys(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/totp') {
|
if (path === '/api/accounts/totp') {
|
||||||
@@ -107,6 +177,66 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTotpRecoveryCode(request, env, userId);
|
return handleGetTotpRecoveryCode(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor') {
|
||||||
|
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
|
||||||
|
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
|
||||||
|
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
|
||||||
|
return handleGetDeviceVerificationSettings(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/device-verification-settings') {
|
||||||
|
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||||
|
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
|
||||||
|
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/authenticator') {
|
||||||
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
|
||||||
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/webauthn') {
|
||||||
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||||
|
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
|
||||||
|
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
return handleGetRevisionDate(request, env, userId);
|
return handleGetRevisionDate(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -119,6 +249,36 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleSetVerifyDevices(request, env, userId);
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||||
|
return handleGetApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||||
|
return handleRotateApiKey(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/webauthn' || path === '/webauthn') {
|
||||||
|
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
|
||||||
|
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
|
||||||
|
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
|
||||||
|
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountPasskeyDeleteMatch =
|
||||||
|
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
|
||||||
|
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
|
||||||
|
if (accountPasskeyDeleteMatch && method === 'POST') {
|
||||||
|
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
return handleSync(request, env, userId);
|
return handleSync(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -191,6 +351,11 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
|
||||||
|
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
if (attachmentDeleteMatch && method === 'POST') {
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||||
@@ -215,8 +380,26 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.startsWith('/api/auth-requests')) {
|
if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
|
||||||
|
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
|
||||||
|
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
|
||||||
|
if (authRequestMatch) {
|
||||||
|
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||||
@@ -281,14 +464,9 @@ export async function handleAuthenticatedRoute(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/settings/domains') {
|
if (path === '/api/settings/domains' || path === '/settings/domains') {
|
||||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
if (method === 'GET') return handleGetDomains(env, userId);
|
||||||
return jsonResponse({
|
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-13
@@ -11,13 +11,21 @@ import {
|
|||||||
handleDeactivateDevice,
|
handleDeactivateDevice,
|
||||||
handleRevokeAllTrustedDevices,
|
handleRevokeAllTrustedDevices,
|
||||||
handleRevokeTrustedDevice,
|
handleRevokeTrustedDevice,
|
||||||
|
handleTrustDevicePermanently,
|
||||||
handleDeleteAllDevices,
|
handleDeleteAllDevices,
|
||||||
handleDeleteDevice,
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceName,
|
||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
handleUpdateDeviceWebPushAuth,
|
handleUpdateDeviceWebPushAuth,
|
||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
|
handleRegisterDevice,
|
||||||
|
handleReportLostTrust,
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
|
|
||||||
|
function devicesPath(pattern: string): RegExp {
|
||||||
|
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAuthenticatedDeviceRoute(
|
export async function handleAuthenticatedDeviceRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -25,25 +33,36 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
path: string,
|
path: string,
|
||||||
method: string
|
method: string
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
if (path === '/api/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/authorized') {
|
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
|
||||||
|
return handleReportLostTrust(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
|
||||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
|
||||||
|
if (permanentAuthorizedDeviceMatch && method === 'POST') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
|
||||||
|
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
|
||||||
if (deleteDeviceMatch && method === 'GET') {
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
return handleGetDevice(request, env, userId, deviceIdentifier);
|
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||||
@@ -53,53 +72,59 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
|
||||||
|
if (updateDeviceNameMatch && method === 'PUT') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||||
|
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
|
||||||
if (identifierMatch && method === 'GET') {
|
if (identifierMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
|
||||||
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||||
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
|
||||||
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
|
||||||
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||||
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
|
||||||
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||||
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
|
||||||
if (identifierRetrieveKeysMatch && method === 'POST') {
|
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||||
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
|
||||||
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||||
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/update-trust' && method === 'POST') {
|
if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
|
||||||
return handleUpdateDeviceTrust(request, env, userId);
|
return handleUpdateDeviceTrust(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/devices/untrust' && method === 'POST') {
|
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
|
||||||
return handleUntrustDevices(request, env, userId);
|
return handleUntrustDevices(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+251
-89
@@ -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,29 +7,51 @@ 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 {
|
import {
|
||||||
handleRegister,
|
handleRegister,
|
||||||
handleGetPasswordHint,
|
handleGetPasswordHint,
|
||||||
handleRecoverTwoFactor,
|
handleRecoverTwoFactor,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
|
import {
|
||||||
|
handleCreateAuthRequest,
|
||||||
|
handleGetAuthRequestResponse,
|
||||||
|
} from './handlers/auth-requests';
|
||||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||||
import { handlePublicUploadAttachment } from './handlers/attachments';
|
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||||
import {
|
import {
|
||||||
|
handleAnonymousNotificationsHub,
|
||||||
handleNotificationsHub,
|
handleNotificationsHub,
|
||||||
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 type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
import { buildConfigResponse } from './config-response';
|
||||||
|
|
||||||
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;
|
||||||
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -52,71 +73,36 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNwIconSvg(): string {
|
function getDefaultWebsiteIconSvg(): string {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNwFavicon(): Response {
|
function handleNwFavicon(): Response {
|
||||||
return new Response(getNwIconSvg(), {
|
return new Response(getDefaultWebsiteIconSvg(), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
function handleMissingWebsiteIcon(): Response {
|
||||||
return `${origin}/icons`;
|
return new Response(null, {
|
||||||
}
|
status: 404,
|
||||||
|
headers: {
|
||||||
function buildIconServiceTemplate(origin: string): string {
|
'Cache-Control': 'public, max-age=300',
|
||||||
return `${buildIconServiceBase(origin)}/{}/icon.png`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIconServiceCsp(origin: string): string {
|
|
||||||
return `img-src 'self' data: ${origin}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigResponse(origin: string) {
|
|
||||||
return {
|
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
|
||||||
gitHash: 'nodewarden',
|
|
||||||
server: null,
|
|
||||||
environment: {
|
|
||||||
cloudRegion: 'self-hosted',
|
|
||||||
vault: origin,
|
|
||||||
api: origin + '/api',
|
|
||||||
identity: origin + '/identity',
|
|
||||||
notifications: origin + '/notifications',
|
|
||||||
icons: origin,
|
|
||||||
sso: '',
|
|
||||||
fillAssistRules: null,
|
|
||||||
},
|
},
|
||||||
push: {
|
});
|
||||||
pushTechnology: 0,
|
|
||||||
vapidPublicKey: null,
|
|
||||||
},
|
|
||||||
communication: null,
|
|
||||||
settings: {
|
|
||||||
disableUserRegistration: false,
|
|
||||||
},
|
|
||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
|
||||||
featureStates: {
|
|
||||||
'duo-redirect': true,
|
|
||||||
'email-verification': true,
|
|
||||||
'pm-19051-send-email-verification': false,
|
|
||||||
'pm-19148-innovation-archive': true,
|
|
||||||
'unauth-ui-refresh': true,
|
|
||||||
'web-push': false,
|
|
||||||
},
|
|
||||||
object: 'config',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIconHost(rawHost: string): string | null {
|
function normalizeIconHost(rawHost: string): string | null {
|
||||||
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(`https://${decoded}`);
|
const parsed = new URL(`https://${decoded}`);
|
||||||
@@ -126,72 +112,176 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
|
||||||
if (!normalizedHost) return handleNwFavicon();
|
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||||
|
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||||
|
|
||||||
const encodedHost = encodeURIComponent(normalizedHost);
|
type IconSource = {
|
||||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
url: string;
|
||||||
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
rejectImage?: {
|
||||||
{
|
byteLength: number;
|
||||||
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
sha256: string;
|
||||||
headers: requestHeaders,
|
};
|
||||||
},
|
headers?: HeadersInit;
|
||||||
{
|
};
|
||||||
url: `https://favicon.im/${encodedHost}`,
|
|
||||||
headers: requestHeaders,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
|
||||||
headers: requestHeaders,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
for (const source of upstreamSources) {
|
return await fetch(source.url, {
|
||||||
const resp = await fetch(source.url, {
|
|
||||||
headers: source.headers,
|
headers: source.headers,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
cf: {
|
cf: {
|
||||||
cacheEverything: true,
|
cacheEverything: true,
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
},
|
},
|
||||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!resp.ok) continue;
|
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
if (!contentType.startsWith('image/')) continue;
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(resp.body, {
|
function getPositiveContentLength(headers: Headers): number | null {
|
||||||
|
const raw = headers.get('Content-Length');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
|
||||||
|
if (!response.body) return null;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
void reader.cancel().catch(() => undefined);
|
||||||
|
}, ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
totalBytes += value.byteLength;
|
||||||
|
if (totalBytes > maxBytes) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut || totalBytes === 0) return null;
|
||||||
|
|
||||||
|
const output = new ArrayBuffer(totalBytes);
|
||||||
|
const bytes = new Uint8Array(output);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||||
|
return new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'Content-Type': contentType || 'image/png',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||||
|
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleNwFavicon();
|
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
|
if (!isWebsiteIconProxyEnabled(env)) {
|
||||||
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHost = normalizeIconHost(host);
|
||||||
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
|
const upstreamSources: IconSource[] = [
|
||||||
|
{
|
||||||
|
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||||
|
rejectImage: {
|
||||||
|
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
|
||||||
|
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
|
||||||
|
},
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const source of upstreamSources) {
|
||||||
|
try {
|
||||||
|
const resp = await fetchIconSource(source);
|
||||||
|
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||||
|
|
||||||
|
const contentLength = getPositiveContentLength(resp.headers);
|
||||||
|
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||||
|
|
||||||
|
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
|
||||||
|
if (!bytes) continue;
|
||||||
|
if (
|
||||||
|
source.rejectImage &&
|
||||||
|
bytes.byteLength === source.rejectImage.byteLength &&
|
||||||
|
(await sha256Hex(bytes)) === source.rejectImage.sha256
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||||
} catch {
|
} catch {
|
||||||
return handleNwFavicon();
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
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;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const userCount = await storage.getUserCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
registrationInviteRequired: userCount > 0,
|
||||||
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,12 +305,34 @@ export async function handlePublicRoute(
|
|||||||
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||||
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;
|
||||||
return jsonResponse(buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/fill-assist/manifest.json' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleFillAssistManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleDigitalAssetLinkCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||||
|
if (fillAssistFormsMatch && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleFillAssistForms(fillAssistFormsMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
return handleWebsiteIcon(iconMatch[1]);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
|
return handleWebsiteIcon(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);
|
||||||
@@ -270,6 +382,19 @@ 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/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleCreateAuthRequest(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||||
|
if (authRequestResponseMatch && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/identity/connect/token' && method === 'POST') {
|
if (path === '/identity/connect/token' && method === 'POST') {
|
||||||
return handleToken(request, env);
|
return handleToken(request, env);
|
||||||
}
|
}
|
||||||
@@ -303,10 +428,41 @@ export async function handlePublicRoute(
|
|||||||
return handlePrelogin(request, env);
|
return handlePrelogin(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleGetAccountPasskeyAssertionOptions(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
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;
|
||||||
@@ -323,7 +479,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') {
|
||||||
@@ -351,5 +507,11 @@ export async function handlePublicRoute(
|
|||||||
if (path === '/notifications/hub' && method === 'GET') {
|
if (path === '/notifications/hub' && method === 'GET') {
|
||||||
return handleNotificationsHub(request, env);
|
return handleNotificationsHub(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAnonymousNotificationsHub(request, env);
|
||||||
|
}
|
||||||
return null;
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
|
||||||
|
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
|
||||||
|
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
|
||||||
|
|
||||||
|
export interface AuditEventInput {
|
||||||
|
actorUserId?: string | null;
|
||||||
|
action: string;
|
||||||
|
category: AuditLogCategory;
|
||||||
|
level?: AuditLogLevel;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
|
||||||
|
const MAX_METADATA_BYTES = 2048;
|
||||||
|
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const AUDIT_CLEANUP_PROBABILITY = 0.02;
|
||||||
|
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
|
||||||
|
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
|
||||||
|
retentionDays: 90,
|
||||||
|
maxEntries: null,
|
||||||
|
};
|
||||||
|
let lastAuditCleanupAt = 0;
|
||||||
|
|
||||||
|
export interface AuditLogSettings {
|
||||||
|
retentionDays: number | null;
|
||||||
|
maxEntries: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_METADATA_KEYS = new Set([
|
||||||
|
'method',
|
||||||
|
'path',
|
||||||
|
'ip',
|
||||||
|
'userAgent',
|
||||||
|
'email',
|
||||||
|
'targetEmail',
|
||||||
|
'grantType',
|
||||||
|
'webSession',
|
||||||
|
'deviceIdentifier',
|
||||||
|
'deviceType',
|
||||||
|
'reason',
|
||||||
|
'status',
|
||||||
|
'verifyDevices',
|
||||||
|
'changed',
|
||||||
|
'removed',
|
||||||
|
'updated',
|
||||||
|
'deleted',
|
||||||
|
'removedTrusted',
|
||||||
|
'removedSessions',
|
||||||
|
'removedDevices',
|
||||||
|
'requested',
|
||||||
|
'count',
|
||||||
|
'requestedCount',
|
||||||
|
'type',
|
||||||
|
'folderId',
|
||||||
|
'cipherId',
|
||||||
|
'size',
|
||||||
|
'users',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
'skippedAttachments',
|
||||||
|
'skippedReason',
|
||||||
|
'replaceExisting',
|
||||||
|
'provider',
|
||||||
|
'prfStatus',
|
||||||
|
'fileName',
|
||||||
|
'fileBytes',
|
||||||
|
'bytes',
|
||||||
|
'compressedBytes',
|
||||||
|
'includesAttachments',
|
||||||
|
'destinationName',
|
||||||
|
'destinationId',
|
||||||
|
'destinationType',
|
||||||
|
'destinationCount',
|
||||||
|
'scheduledDestinationCount',
|
||||||
|
'retentionDays',
|
||||||
|
'maxEntries',
|
||||||
|
'remotePath',
|
||||||
|
'trigger',
|
||||||
|
'prunedFileCount',
|
||||||
|
'pruneError',
|
||||||
|
'uploadVerificationAttempts',
|
||||||
|
'error',
|
||||||
|
'expiresInHours',
|
||||||
|
'checksumMismatchAccepted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
|
||||||
|
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
|
||||||
|
const parsed = Math.floor(Number(value));
|
||||||
|
return allowed.includes(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
|
||||||
|
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||||
|
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
|
||||||
|
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
|
||||||
|
|
||||||
|
if (retentionDays) return { retentionDays, maxEntries: null };
|
||||||
|
if (maxEntries) return { retentionDays: null, maxEntries };
|
||||||
|
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
|
||||||
|
return { retentionDays: null, maxEntries: null };
|
||||||
|
}
|
||||||
|
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
|
||||||
|
return { retentionDays: null, maxEntries: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_AUDIT_LOG_SETTINGS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditRequestMetadata(request: Request): Record<string, unknown> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
path: url.pathname,
|
||||||
|
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
|
||||||
|
userAgent: request.headers.get('User-Agent') || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const clean: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(metadata)) {
|
||||||
|
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (SENSITIVE_KEY_RE.test(key)) continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
clean[key] = value.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') continue;
|
||||||
|
clean[key] = value;
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
|
||||||
|
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||||
|
try {
|
||||||
|
return normalizeAuditLogSettings(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
|
||||||
|
const normalized = normalizeAuditLogSettings(settings);
|
||||||
|
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
|
||||||
|
await applyAuditLogRetention(storage, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
|
||||||
|
const current = settings || await getAuditLogSettings(storage);
|
||||||
|
if (current.retentionDays) {
|
||||||
|
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
await storage.pruneAuditLogs(before);
|
||||||
|
}
|
||||||
|
if (current.maxEntries) {
|
||||||
|
await storage.pruneAuditLogsToMax(current.maxEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
|
||||||
|
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
|
||||||
|
lastAuditCleanupAt = now;
|
||||||
|
await applyAuditLogRetention(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||||
|
const metadata = sanitizeMetadata(event.metadata || {});
|
||||||
|
let metadataJson = JSON.stringify(metadata);
|
||||||
|
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
|
||||||
|
metadataJson = JSON.stringify({ truncated: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId: event.actorUserId ?? null,
|
||||||
|
action: event.action,
|
||||||
|
category: event.category,
|
||||||
|
level: event.level || 'info',
|
||||||
|
targetType: event.targetType ?? null,
|
||||||
|
targetId: event.targetId ?? null,
|
||||||
|
metadata: metadataJson,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await maybePruneAuditLogs(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await insertAuditEvent(storage, event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('audit log write failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
|
||||||
|
await writeAuditEvent(new StorageService(env.DB), event);
|
||||||
|
}
|
||||||
+185
-24
@@ -1,27 +1,142 @@
|
|||||||
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.
|
||||||
// The client already does heavy PBKDF2 (600k iterations).
|
// The client already does heavy PBKDF2 (600k iterations).
|
||||||
// This second layer only needs to be non-trivial, not expensive.
|
// This second layer only needs to be non-trivial, not expensive.
|
||||||
const SERVER_HASH_ITERATIONS = 100_000;
|
const SERVER_HASH_ITERATIONS = 100_000;
|
||||||
|
const SERVER_HASH_PREFIX = '$s$';
|
||||||
|
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||||
|
|
||||||
|
interface CachedUserEntry {
|
||||||
|
user: User | null;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedDeviceEntry {
|
||||||
|
device: Awaited<ReturnType<StorageService['getDevice']>>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VerifiedAccessContext {
|
export interface VerifiedAccessContext {
|
||||||
payload: JWTPayload;
|
payload: JWTPayload;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RefreshAccessTokenFailureReason =
|
||||||
|
| 'token_not_found_or_expired'
|
||||||
|
| 'user_missing'
|
||||||
|
| 'user_inactive'
|
||||||
|
| 'security_stamp_mismatch'
|
||||||
|
| 'device_missing'
|
||||||
|
| 'device_session_mismatch';
|
||||||
|
|
||||||
|
export type RefreshAccessTokenResult =
|
||||||
|
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: RefreshAccessTokenFailureReason;
|
||||||
|
userId?: string | null;
|
||||||
|
deviceIdentifier?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private storage: StorageService;
|
private storage: StorageService;
|
||||||
|
private static userCache = new Map<string, CachedUserEntry>();
|
||||||
|
private static deviceCache = new Map<string, CachedDeviceEntry>();
|
||||||
|
|
||||||
constructor(private env: Env) {
|
constructor(private env: Env) {
|
||||||
this.storage = new StorageService(env.DB);
|
this.storage = new StorageService(env.DB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static invalidateUserCache(userId: string): void {
|
||||||
|
const normalizedUserId = String(userId || '').trim();
|
||||||
|
if (!normalizedUserId) return;
|
||||||
|
AuthService.userCache.delete(normalizedUserId);
|
||||||
|
const prefix = `${normalizedUserId}:`;
|
||||||
|
for (const key of AuthService.deviceCache.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
AuthService.deviceCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidateDeviceCache(userId: string, deviceId: string): void {
|
||||||
|
const normalizedUserId = String(userId || '').trim();
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim();
|
||||||
|
if (!normalizedUserId || !normalizedDeviceId) return;
|
||||||
|
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCachedUser(userId: string): User | null | undefined {
|
||||||
|
const cached = AuthService.userCache.get(userId);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
AuthService.userCache.delete(userId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCachedUser(userId: string, user: User | null): void {
|
||||||
|
AuthService.userCache.set(userId, {
|
||||||
|
user,
|
||||||
|
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCachedUser(userId: string): Promise<User | null> {
|
||||||
|
const cached = this.readCachedUser(userId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
this.writeCachedUser(userId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFreshUser(userId: string): Promise<User | null> {
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
this.writeCachedUser(userId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readCachedDevice(userId: string, deviceId: string) {
|
||||||
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
|
const cached = AuthService.deviceCache.get(cacheKey);
|
||||||
|
if (!cached) return undefined;
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
AuthService.deviceCache.delete(cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cached.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
|
||||||
|
const cacheKey = `${userId}:${deviceId}`;
|
||||||
|
AuthService.deviceCache.set(cacheKey, {
|
||||||
|
device,
|
||||||
|
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCachedDevice(userId: string, deviceId: string) {
|
||||||
|
const cached = this.readCachedDevice(userId, deviceId);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const device = await this.storage.getDevice(userId, deviceId);
|
||||||
|
this.writeCachedDevice(userId, deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFreshDevice(userId: string, deviceId: string) {
|
||||||
|
const device = await this.storage.getDevice(userId, deviceId);
|
||||||
|
this.writeCachedDevice(userId, deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -39,20 +154,17 @@ export class AuthService {
|
|||||||
const bytes = new Uint8Array(bits);
|
const bytes = new Uint8Array(bits);
|
||||||
let binary = '';
|
let binary = '';
|
||||||
for (const b of bytes) binary += String.fromCharCode(b);
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
return '$s$' + btoa(binary);
|
return SERVER_HASH_PREFIX + btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password: hash the input the same way, then constant-time compare.
|
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||||
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
|
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||||
// New server-hashed passwords are prefixed with "$s$".
|
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||||
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
|
return this.constantTimeEquals(inputHash, storedHash);
|
||||||
if (email && storedHash.startsWith('$s$')) {
|
}
|
||||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||||
return this.constantTimeEquals(serverHash, storedHash);
|
return this.constantTimeEquals(serverHash, storedHash);
|
||||||
}
|
}
|
||||||
// Legacy path: direct constant-time comparison of raw client hashes.
|
|
||||||
return this.constantTimeEquals(inputHash, storedHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
private constantTimeEquals(a: string, b: string): boolean {
|
private constantTimeEquals(a: string, b: string): boolean {
|
||||||
const encA = new TextEncoder().encode(a);
|
const encA = new TextEncoder().encode(a);
|
||||||
@@ -80,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,15 +223,22 @@ export class AuthService {
|
|||||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
|
|
||||||
const user = await this.storage.getUserById(payload.sub);
|
let user = await this.getCachedUser(payload.sub);
|
||||||
|
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
|
||||||
|
user = await this.getFreshUser(payload.sub);
|
||||||
|
}
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
if (user.status !== 'active') return null;
|
||||||
|
|
||||||
if (payload.sstamp !== user.securityStamp) {
|
if (payload.sstamp !== user.securityStamp) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.did) {
|
if (payload.did) {
|
||||||
const device = await this.storage.getDevice(user.id, payload.did);
|
let device = await this.getCachedDevice(user.id, payload.did);
|
||||||
|
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
|
||||||
|
device = await this.getFreshDevice(user.id, payload.did);
|
||||||
|
}
|
||||||
if (!device) return null;
|
if (!device) return null;
|
||||||
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
}
|
}
|
||||||
@@ -120,17 +253,26 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
// Refresh access token
|
||||||
async refreshAccessToken(
|
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
|
||||||
refreshToken: string
|
|
||||||
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
|
||||||
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||||
if (!record?.userId) return null;
|
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
|
||||||
|
|
||||||
const user = await this.storage.getUserById(record.userId);
|
const user = await this.storage.getUserById(record.userId);
|
||||||
if (!user) return null;
|
if (!user) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.securityStamp && record.securityStamp !== user.securityStamp) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
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;
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
@@ -138,16 +280,35 @@ export class AuthService {
|
|||||||
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 null;
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
}
|
}
|
||||||
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
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 { accessToken, user, device };
|
return { ok: true, accessToken, user, device, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccessToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||||
|
const result = await this.refreshAccessTokenDetailed(refreshToken);
|
||||||
|
return result.ok ? result : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+192
-25
@@ -1,23 +1,41 @@
|
|||||||
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 { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||||
|
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
|
||||||
import {
|
import {
|
||||||
getAttachmentObjectKey,
|
getAttachmentObjectKey,
|
||||||
getBlobStorageKind,
|
getBlobStorageKind,
|
||||||
} from './blob-store';
|
} from './blob-store';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// This file defines the exported instance-backup archive shape. Keep it in lock
|
||||||
|
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
|
||||||
|
// and validateBackupPayloadContents().
|
||||||
|
// - Keep secrets and transient runtime rows sanitized before writing db.json.
|
||||||
|
// - Runtime authentication state (devices, sessions, auth requests, remembered
|
||||||
|
// 2FA devices, and one-time tokens) must never enter an instance backup.
|
||||||
|
// - users.api_key is intentionally not exported.
|
||||||
|
// - backup.settings.v1 is exported as portable-only; the current server runtime
|
||||||
|
// envelope must not leave the instance.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
const BACKUP_FORMAT_VERSION = 1;
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
|
||||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
// 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;
|
||||||
@@ -48,10 +66,12 @@ export interface BackupPayload {
|
|||||||
db: {
|
db: {
|
||||||
config: SqlRow[];
|
config: SqlRow[];
|
||||||
users: SqlRow[];
|
users: SqlRow[];
|
||||||
|
domain_settings: SqlRow[];
|
||||||
user_revisions: SqlRow[];
|
user_revisions: SqlRow[];
|
||||||
folders: SqlRow[];
|
folders: SqlRow[];
|
||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
|
webauthn_credentials?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +91,7 @@ export interface BackupFileIntegrityCheckResult {
|
|||||||
export interface BuildBackupArchiveOptions {
|
export interface BuildBackupArchiveOptions {
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
progress?: BackupArchiveBuildProgressReporter;
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupArchiveBuildProgressEvent {
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
@@ -88,22 +109,52 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
|||||||
return (result.results || []).map((row) => ({ ...row }));
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
|
||||||
|
const sanitized: SqlRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY || key === YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY) continue;
|
||||||
|
|
||||||
|
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
|
||||||
|
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
|
||||||
|
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized.push({ ...row });
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
function getDateParts(date: Date, timeZone: string): string {
|
||||||
const parts = [
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
timeZone,
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
year: 'numeric',
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
month: '2-digit',
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
day: '2-digit',
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
hour: '2-digit',
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
minute: '2-digit',
|
||||||
];
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileNameInTimeZone(
|
||||||
|
date: Date = new Date(),
|
||||||
|
checksumPrefix: string | null = null,
|
||||||
|
timeZone: string = 'UTC'
|
||||||
|
): string {
|
||||||
|
const parts = getDateParts(date, timeZone);
|
||||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
@@ -138,6 +189,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) {
|
||||||
@@ -156,6 +262,25 @@ function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
|||||||
return value as SqlRow[];
|
return value as SqlRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeParsedBackupDb(value: unknown): BackupPayload['db'] {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error('Backup archive database payload is invalid');
|
||||||
|
}
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
// Restore uses an explicit allowlist. Extra tables from old or modified
|
||||||
|
// archives, especially runtime authentication state, are intentionally ignored.
|
||||||
|
return {
|
||||||
|
config: source.config as SqlRow[],
|
||||||
|
users: source.users as SqlRow[],
|
||||||
|
domain_settings: source.domain_settings as SqlRow[],
|
||||||
|
user_revisions: source.user_revisions as SqlRow[],
|
||||||
|
folders: source.folders as SqlRow[],
|
||||||
|
ciphers: source.ciphers as SqlRow[],
|
||||||
|
attachments: source.attachments as SqlRow[],
|
||||||
|
webauthn_credentials: source.webauthn_credentials as SqlRow[] | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
|
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
|
||||||
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
|
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
|
||||||
for (const [path, bytes] of Object.entries(files)) {
|
for (const [path, bytes] of Object.entries(files)) {
|
||||||
@@ -175,8 +300,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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +315,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) {
|
||||||
@@ -205,10 +334,10 @@ export function parseBackupArchive(
|
|||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let manifest: BackupManifest;
|
let manifest: BackupManifest;
|
||||||
let db: BackupPayload['db'];
|
let rawDb: unknown;
|
||||||
try {
|
try {
|
||||||
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||||
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
rawDb = JSON.parse(decoder.decode(dbBytes));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Backup archive contains invalid JSON metadata');
|
throw new Error('Backup archive contains invalid JSON metadata');
|
||||||
}
|
}
|
||||||
@@ -216,9 +345,7 @@ export function parseBackupArchive(
|
|||||||
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||||
throw new Error('Unsupported backup format version');
|
throw new Error('Unsupported backup format version');
|
||||||
}
|
}
|
||||||
if (!db || typeof db !== 'object') {
|
const db = normalizeParsedBackupDb(rawDb);
|
||||||
throw new Error('Backup archive database payload is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
@@ -250,9 +377,11 @@ export function validateBackupPayloadContents(
|
|||||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
|
||||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
@@ -280,6 +409,18 @@ export function validateBackupPayloadContents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const domainSettingUserIds = new Set<string>();
|
||||||
|
for (const row of domainSettingsRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
if (domainSettingUserIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
|
||||||
|
}
|
||||||
|
domainSettingUserIds.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
const folderIds = new Set<string>();
|
const folderIds = new Set<string>();
|
||||||
for (const row of folderRows) {
|
for (const row of folderRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
@@ -305,7 +446,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`;
|
||||||
@@ -313,6 +454,24 @@ export function validateBackupPayloadContents(
|
|||||||
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountPasskeyIds = new Set<string>();
|
||||||
|
const accountPasskeyCredentialIds = new Set<string>();
|
||||||
|
for (const row of accountPasskeyRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
|
||||||
|
const credentialId = String(row.credential_id || '').trim();
|
||||||
|
const publicKey = String(row.public_key || '').trim();
|
||||||
|
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
|
||||||
|
throw new Error('Backup archive contains an invalid account passkey row');
|
||||||
|
}
|
||||||
|
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||||
|
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
|
||||||
|
accountPasskeyIds.add(id);
|
||||||
|
accountPasskeyCredentialIds.add(credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -331,14 +490,17 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = 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, 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, 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'),
|
||||||
]);
|
]);
|
||||||
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
@@ -357,12 +519,14 @@ export async function buildBackupArchive(
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
storageKind: getBlobStorageKind(env),
|
storageKind: getBlobStorageKind(env),
|
||||||
tableCounts: {
|
tableCounts: {
|
||||||
config: configRows.length,
|
config: exportedConfigRows.length,
|
||||||
users: userRows.length,
|
users: userRows.length,
|
||||||
|
domain_settings: domainSettingsRows.length,
|
||||||
user_revisions: revisionRows.length,
|
user_revisions: revisionRows.length,
|
||||||
folders: folderRows.length,
|
folders: folderRows.length,
|
||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
attachments: exportedAttachmentRows.length,
|
attachments: exportedAttachmentRows.length,
|
||||||
|
webauthn_credentials: accountPasskeyRows.length,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -378,12 +542,14 @@ export async function buildBackupArchive(
|
|||||||
const files: Record<string, Uint8Array> = {
|
const files: Record<string, Uint8Array> = {
|
||||||
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||||
'db.json': encoder.encode(JSON.stringify({
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
config: configRows,
|
config: exportedConfigRows,
|
||||||
users: userRows,
|
users: userRows,
|
||||||
|
domain_settings: domainSettingsRows,
|
||||||
user_revisions: revisionRows,
|
user_revisions: revisionRows,
|
||||||
folders: folderRows,
|
folders: folderRows,
|
||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
|
webauthn_credentials: accountPasskeyRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,7 +564,8 @@ export async function buildBackupArchive(
|
|||||||
});
|
});
|
||||||
const bytes = zipSync(createZipEntries(files));
|
const bytes = zipSync(createZipEntries(files));
|
||||||
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
const fileName = buildBackupFileName(date, fileHashPrefix);
|
const backupTimeZone = options.timeZone || 'UTC';
|
||||||
|
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
|
||||||
await options.progress?.({
|
await options.progress?.({
|
||||||
step: 'archive_ready',
|
step: 'archive_ready',
|
||||||
fileName,
|
fileName,
|
||||||
|
|||||||
+354
-43
@@ -16,7 +16,8 @@ import {
|
|||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
type E3BackupDestination,
|
type S3BackupAddressingStyle,
|
||||||
|
type S3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
createBackupRandomId,
|
createBackupRandomId,
|
||||||
createDefaultBackupDestinationName,
|
createDefaultBackupDestinationName,
|
||||||
@@ -25,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 {
|
||||||
@@ -35,7 +38,8 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
E3BackupDestination,
|
S3BackupAddressingStyle,
|
||||||
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
@@ -64,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());
|
||||||
@@ -105,32 +266,36 @@ function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_ST
|
|||||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
|
||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
const bucket = asTrimmedString(source.bucket);
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||||
|
const addressingStyle: S3BackupAddressingStyle =
|
||||||
|
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
const region = asTrimmedString(source.region) || 'auto';
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
const rootPath = normalizePath(source.rootPath);
|
const rootPath = normalizePath(source.rootPath);
|
||||||
|
|
||||||
if (!allowIncomplete || endpoint) {
|
if (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('E3 endpoint is required');
|
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || bucket) {
|
if (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('E3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || accessKeyId) {
|
if (!allowIncomplete || accessKeyId) {
|
||||||
if (!accessKeyId) throw new Error('E3 access key is required');
|
if (!accessKeyId) throw new Error('S3 access key is required');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || secretAccessKey) {
|
if (!allowIncomplete || secretAccessKey) {
|
||||||
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
if (!secretAccessKey) throw new Error('S3 secret key is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||||
bucket,
|
bucket,
|
||||||
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
@@ -147,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');
|
||||||
@@ -157,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,
|
||||||
@@ -169,10 +334,36 @@ function normalizeDestination(
|
|||||||
destination: unknown,
|
destination: unknown,
|
||||||
allowIncomplete = false
|
allowIncomplete = false
|
||||||
): BackupDestinationConfig {
|
): BackupDestinationConfig {
|
||||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
|
||||||
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 => {
|
||||||
@@ -204,7 +395,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
|
|||||||
|
|
||||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||||
const value = asTrimmedString(raw);
|
const value = asTrimmedString(raw);
|
||||||
if (value === 'e3' || value === 'webdav') return value;
|
if (value === 'e3') return 's3';
|
||||||
|
if (value === 's3' || value === 'webdav') return value;
|
||||||
throw new Error('Backup destination type is invalid');
|
throw new Error('Backup destination type is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,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,
|
||||||
@@ -266,8 +462,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
|||||||
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
const destinationType: BackupDestinationType =
|
const destinationType: BackupDestinationType =
|
||||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
|
||||||
? destinationTypeRaw
|
? getDestinationType(destinationTypeRaw)
|
||||||
: 'webdav';
|
: 'webdav';
|
||||||
const destination = {
|
const destination = {
|
||||||
id: createBackupRandomId(),
|
id: createBackupRandomId(),
|
||||||
@@ -317,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));
|
||||||
}
|
}
|
||||||
@@ -380,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');
|
||||||
}
|
}
|
||||||
@@ -408,15 +673,29 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
|
|||||||
|
|
||||||
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
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> {
|
||||||
@@ -441,12 +720,6 @@ export async function normalizeImportedBackupSettingsValue(
|
|||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
return serializeBackupSettings(settings);
|
|
||||||
}
|
|
||||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
@@ -454,12 +727,6 @@ export async function normalizeImportedBackupSettingsValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
return serializeBackupSettings(settings);
|
|
||||||
}
|
|
||||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +865,50 @@ function getBackupSlotStartsForLocalDay(
|
|||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasBackupSlotBetween(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
startInclusive: Date,
|
||||||
|
endExclusive: Date
|
||||||
|
): boolean {
|
||||||
|
if (!destination.schedule.enabled) return false;
|
||||||
|
const startMs = startInclusive.getTime();
|
||||||
|
const endMs = endExclusive.getTime();
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||||
|
|
||||||
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
|
? lastSuccessAt.getTime()
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
const dayCursor = new Date(startMs);
|
||||||
|
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endDay = new Date(endMs);
|
||||||
|
endDay.setUTCHours(0, 0, 0, 0);
|
||||||
|
const checkedLocalDateKeys = new Set<string>();
|
||||||
|
|
||||||
|
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
|
||||||
|
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
|
||||||
|
if (!checkedLocalDateKeys.has(localDateKey)) {
|
||||||
|
checkedLocalDateKeys.add(localDateKey);
|
||||||
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
|
localDateKey,
|
||||||
|
destination.schedule.timezone,
|
||||||
|
destination.schedule.startTime,
|
||||||
|
destination.schedule.intervalHours
|
||||||
|
);
|
||||||
|
for (const slotStart of slotStarts) {
|
||||||
|
const slotStartMs = slotStart.getTime();
|
||||||
|
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||||
|
if (lastSuccessMs >= slotStartMs) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isBackupDueNow(
|
export function isBackupDueNow(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
now: Date,
|
now: Date,
|
||||||
@@ -605,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(
|
||||||
@@ -620,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;
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||||
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||||
|
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
|
||||||
import {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
|
isSafeBackupAttachmentBlobName,
|
||||||
parseBackupArchive,
|
parseBackupArchive,
|
||||||
validateBackupPayloadContents,
|
validateBackupPayloadContents,
|
||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Restore is intentionally whitelist-based. Old backups may contain retired
|
||||||
|
// fields, but only the columns listed here are imported. Keep this file in sync
|
||||||
|
// with src/services/backup-archive.ts whenever backup contents change.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
|
||||||
|
// shadow-table count validation, insert column lists, and frontend import
|
||||||
|
// count types together.
|
||||||
|
// - Do not import users.api_key, even if an older backup contains it.
|
||||||
|
// - Do not import, clear, or replace runtime authentication state such as
|
||||||
|
// devices, sessions, auth requests, or remembered 2FA device tokens.
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
type BackupTableName =
|
type BackupTableName =
|
||||||
| 'config'
|
| 'config'
|
||||||
| 'users'
|
| 'users'
|
||||||
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
| 'attachments';
|
| 'attachments';
|
||||||
@@ -20,7 +36,9 @@ type BackupTableName =
|
|||||||
const BACKUP_TABLES: BackupTableName[] = [
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
'config',
|
'config',
|
||||||
'users',
|
'users',
|
||||||
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
'attachments',
|
'attachments',
|
||||||
@@ -35,7 +53,9 @@ export interface BackupImportResultBody {
|
|||||||
imported: {
|
imported: {
|
||||||
config: number;
|
config: number;
|
||||||
users: number;
|
users: number;
|
||||||
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
attachments: number;
|
attachments: number;
|
||||||
@@ -155,6 +175,8 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM attachments',
|
'DELETE FROM attachments',
|
||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM webauthn_credentials',
|
||||||
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
'DELETE FROM config',
|
'DELETE FROM config',
|
||||||
@@ -231,6 +253,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) => {
|
||||||
@@ -249,7 +275,9 @@ async function prepareImportedConfigRows(
|
|||||||
configRows: SqlRow[],
|
configRows: SqlRow[],
|
||||||
userRows: SqlRow[]
|
userRows: SqlRow[]
|
||||||
): Promise<SqlRow[]> {
|
): Promise<SqlRow[]> {
|
||||||
let nextConfigRows = cloneRows(configRows || []);
|
let nextConfigRows = cloneRows(configRows || []).filter(
|
||||||
|
(row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY
|
||||||
|
);
|
||||||
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||||
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||||
@@ -274,9 +302,15 @@ 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 || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
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,
|
||||||
@@ -436,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(
|
||||||
@@ -594,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 || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -603,6 +648,27 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
tableName('user_revisions'),
|
tableName('user_revisions'),
|
||||||
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('domain_settings'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('domain_settings'),
|
||||||
|
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
|
||||||
|
payload.domain_settings || [],
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('webauthn_credentials'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('webauthn_credentials'),
|
||||||
|
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||||
|
payload.webauthn_credentials || []
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('folders'),
|
tableName('folders'),
|
||||||
@@ -669,7 +735,9 @@ export async function importBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: (db.attachments || []).length,
|
attachments: (db.attachments || []).length,
|
||||||
@@ -690,7 +758,9 @@ export async function importBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -729,7 +799,9 @@ export async function importBackupArchiveBytes(
|
|||||||
imported: {
|
imported: {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -804,7 +876,9 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: (db.attachments || []).length,
|
attachments: (db.attachments || []).length,
|
||||||
@@ -825,7 +899,9 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
await validateShadowTableCounts(env.DB, {
|
await validateShadowTableCounts(env.DB, {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -870,7 +946,9 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
imported: {
|
imported: {
|
||||||
config: (db.config || []).length,
|
config: (db.config || []).length,
|
||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Backup settings contain provider credentials. They are stored as a v2 envelope:
|
||||||
|
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
|
||||||
|
// server's scheduled backup runner.
|
||||||
|
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||||
|
// active admin public keys so settings can be repaired after restore/migration.
|
||||||
|
// Historical/imported databases may not have usable admin public keys; in that
|
||||||
|
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
|
||||||
|
//
|
||||||
|
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||||
|
// pattern or a deliberately documented replacement. Do not store provider
|
||||||
|
// secrets as plain config JSON.
|
||||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
const RUNTIME_INFO = 'runtime';
|
const RUNTIME_INFO = 'runtime';
|
||||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
@@ -155,6 +167,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) return null;
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
portableOnly: true,
|
||||||
|
runtime: {
|
||||||
|
iv: '',
|
||||||
|
ciphertext: '',
|
||||||
|
},
|
||||||
|
portable: envelope.portable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function encryptBackupSettingsEnvelope(
|
export async function encryptBackupSettingsEnvelope(
|
||||||
plaintext: string,
|
plaintext: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -162,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const eligibleUsers = getEligiblePortableUsers(users);
|
const eligibleUsers = getEligiblePortableUsers(users);
|
||||||
if (!eligibleUsers.length) {
|
|
||||||
throw new Error('No active administrator public keys are available for backup settings recovery');
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||||
@@ -181,6 +204,7 @@ export async function encryptBackupSettingsEnvelope(
|
|||||||
|
|
||||||
const wraps: BackupSettingsPortableWrap[] = [];
|
const wraps: BackupSettingsPortableWrap[] = [];
|
||||||
for (const user of eligibleUsers) {
|
for (const user of eligibleUsers) {
|
||||||
|
try {
|
||||||
const publicKey = await importPortablePublicKey(user.publicKey!);
|
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||||
const wrappedKey = new Uint8Array(
|
const wrappedKey = new Uint8Array(
|
||||||
await crypto.subtle.encrypt(
|
await crypto.subtle.encrypt(
|
||||||
@@ -193,6 +217,9 @@ export async function encryptBackupSettingsEnvelope(
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
wrappedKey: bytesToBase64(wrappedKey),
|
wrappedKey: bytesToBase64(wrappedKey),
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep runtime settings usable even if an imported admin key is malformed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope: BackupSettingsEnvelopeV2 = {
|
const envelope: BackupSettingsEnvelopeV2 = {
|
||||||
|
|||||||
+133
-74
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
E3BackupDestination,
|
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,18 +216,18 @@ 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;
|
||||||
}
|
}
|
||||||
if (destination.type === 'e3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as E3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('E3 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('E3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
|
||||||
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||||
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('E3 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,23 +453,48 @@ 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 e3BucketBaseUrl(config: E3BackupDestination): URL {
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
const hostname = endpoint.hostname.toLowerCase();
|
||||||
|
const bucketName = bucket.trim().toLowerCase();
|
||||||
|
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||||
|
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||||
|
const bucket = config.bucket.trim();
|
||||||
|
|
||||||
|
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||||
|
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||||
|
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||||
|
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signedE3Request(
|
async function signedS3Request(
|
||||||
config: E3BackupDestination,
|
config: S3BackupDestination,
|
||||||
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||||
url: URL,
|
url: URL,
|
||||||
body?: Uint8Array,
|
body?: Uint8Array,
|
||||||
@@ -482,7 +519,7 @@ async function signedE3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
@@ -494,46 +531,50 @@ async function signedE3Request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function putToE3(
|
async function putToS3(
|
||||||
config: E3BackupDestination,
|
config: S3BackupDestination,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 upload failed: ${response.status}`);
|
throw new Error(`S3 upload failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
remotePath: normalizeE3ObjectKey(config, fileName),
|
remotePath: normalizeS3ObjectKey(config, fileName),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
const url = e3BucketBaseUrl(config);
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
let continuationToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
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 signedE3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 listing failed: ${response.status}`);
|
throw new Error(`S3 listing failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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') || '');
|
||||||
@@ -577,30 +618,33 @@ async function listE3Entries(config: E3BackupDestination, 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);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
currentPath,
|
currentPath,
|
||||||
parentPath: parentPath(currentPath),
|
parentPath: parentPath(currentPath),
|
||||||
items: sortRemoteItems(Array.from(deduped.values())),
|
items: sortRemoteItems(Array.from(deduped.values())),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
if (!normalized || normalized.endsWith('/')) {
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
throw new Error('Please select a backup file');
|
throw new Error('Please select a backup file');
|
||||||
}
|
}
|
||||||
const objectKey = normalizeE3ObjectKey(config, normalized);
|
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedE3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`E3 download failed: ${response.status}`);
|
throw new Error(`S3 download failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
remotePath: normalized,
|
remotePath: normalized,
|
||||||
fileName: basename(normalized) || 'backup.zip',
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
@@ -608,35 +652,46 @@ async function downloadFromE3(config: E3BackupDestination, relativePath: string)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedE3Request(config, 'DELETE', url);
|
const response = await signedS3Request(config, 'DELETE', url);
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`E3 delete failed: ${response.status}`);
|
throw new Error(`S3 delete failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
return (await statS3File(config, relativePath)) !== null;
|
||||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
|
||||||
const response = await signedE3Request(config, 'HEAD', url);
|
|
||||||
if (response.status === 404) return false;
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`E3 existence check failed: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
|
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||||
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
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 {
|
||||||
provider: 'webdav' | 'e3';
|
provider: 'webdav' | 's3';
|
||||||
config: WebDavBackupDestination | E3BackupDestination;
|
config: WebDavBackupDestination | S3BackupDestination;
|
||||||
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||||
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||||
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||||
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
exists: (config: WebDavBackupDestination | E3BackupDestination, 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 {
|
||||||
@@ -647,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(
|
||||||
@@ -664,18 +720,20 @@ 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 === 'e3') {
|
if (destination.type === 's3') {
|
||||||
return {
|
return {
|
||||||
provider: 'e3',
|
provider: 's3',
|
||||||
config: destination.destination as E3BackupDestination,
|
config: destination.destination as S3BackupDestination,
|
||||||
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
|
||||||
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
|
||||||
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
|
||||||
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +761,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
|||||||
provider: adapter.provider,
|
provider: adapter.provider,
|
||||||
remotePath: adapter.provider === 'webdav'
|
remotePath: adapter.provider === 'webdav'
|
||||||
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||||
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
putFile,
|
putFile,
|
||||||
@@ -711,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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||||
|
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||||
|
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||||
|
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||||
|
|
||||||
|
// CONTRACT:
|
||||||
|
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
|
||||||
|
// the full custom rule list and the derived active equivalent-domain groups:
|
||||||
|
// - custom_equivalent_domains: UI/client rules with id + excluded state.
|
||||||
|
// - equivalent_domains: active groups derived from non-excluded custom rules.
|
||||||
|
// - excluded_global_equivalent_domains: disabled global rule type ids.
|
||||||
|
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
|
||||||
|
// duplicates without a migration and compatibility plan.
|
||||||
|
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||||
|
Type?: unknown;
|
||||||
|
Domains?: unknown;
|
||||||
|
Excluded?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDomain(value: unknown): string {
|
||||||
|
return normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||||
|
const type = Number(entry.type ?? entry.Type);
|
||||||
|
if (!Number.isInteger(type)) return null;
|
||||||
|
|
||||||
|
const rawDomains = entry.domains ?? entry.Domains;
|
||||||
|
if (!Array.isArray(rawDomains)) return null;
|
||||||
|
|
||||||
|
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: GlobalEquivalentDomain[] = [];
|
||||||
|
for (const entry of input) {
|
||||||
|
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||||
|
if (!normalized || seen.has(normalized.type)) continue;
|
||||||
|
seen.add(normalized.type);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||||
|
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||||
|
|
||||||
|
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||||
|
...bitwardenGlobalDomains,
|
||||||
|
...customGlobalDomains,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const group of input) {
|
||||||
|
if (!Array.isArray(group)) continue;
|
||||||
|
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) continue;
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
groups.push(domains);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
|
||||||
|
function find(domain: string): string {
|
||||||
|
const current = parent.get(domain);
|
||||||
|
if (!current) {
|
||||||
|
parent.set(domain, domain);
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
if (current === domain) return domain;
|
||||||
|
const root = find(current);
|
||||||
|
parent.set(domain, root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function union(a: string, b: string): void {
|
||||||
|
const rootA = find(a);
|
||||||
|
const rootB = find(b);
|
||||||
|
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of normalizeEquivalentDomains(input)) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const [first, ...rest] = group;
|
||||||
|
find(first);
|
||||||
|
for (const domain of rest) union(first, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = new Map<string, string[]>();
|
||||||
|
for (const domain of parent.keys()) {
|
||||||
|
const root = find(domain);
|
||||||
|
const group = components.get(root) || [];
|
||||||
|
group.push(domain);
|
||||||
|
components.set(root, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(components.values())
|
||||||
|
.map((group) => group.sort())
|
||||||
|
.filter((group) => group.length >= 2)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
customGroups: string[][],
|
||||||
|
activeGlobalGroups: string[][]
|
||||||
|
): string[][] {
|
||||||
|
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||||
|
if (!normalizedCustomGroups.length) return [];
|
||||||
|
|
||||||
|
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||||
|
return mergeEquivalentDomainGroups([
|
||||||
|
...activeGlobalGroups,
|
||||||
|
...normalizedCustomGroups,
|
||||||
|
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomDomainId(domains: string[], index: number): string {
|
||||||
|
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const rules: CustomEquivalentDomain[] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const [index, item] of input.entries()) {
|
||||||
|
const record = Array.isArray(item)
|
||||||
|
? { domains: item, excluded: false, id: '' }
|
||||||
|
: item && typeof item === 'object'
|
||||||
|
? item as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
|
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||||
|
if (!domains) continue;
|
||||||
|
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
|
||||||
|
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||||
|
rules.push({
|
||||||
|
id: rawId || createCustomDomainId(domains, index),
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||||
|
return mergeEquivalentDomainGroups(rules
|
||||||
|
.filter((rule) => !rule.excluded)
|
||||||
|
.map((rule) => rule.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const item of input) {
|
||||||
|
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||||
|
const excluded = typeof item === 'object' && item !== null
|
||||||
|
? Boolean((item as Record<string, unknown>).excluded)
|
||||||
|
: true;
|
||||||
|
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||||
|
seen.add(type);
|
||||||
|
out.push(type);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsResponse(
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
options: { omitExcludedGlobals?: boolean } = {}
|
||||||
|
): DomainRulesResponse {
|
||||||
|
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||||
|
const activeGlobalDomainGroups = globalDomains
|
||||||
|
.filter((entry) => !excluded.has(entry.type))
|
||||||
|
.map((entry) => entry.domains);
|
||||||
|
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
equivalentDomains,
|
||||||
|
activeGlobalDomainGroups
|
||||||
|
);
|
||||||
|
const globals = globalDomains
|
||||||
|
.map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
|
domains: entry.domains,
|
||||||
|
excluded: excluded.has(entry.type),
|
||||||
|
}))
|
||||||
|
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
equivalentDomains: mergedEquivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains: globals,
|
||||||
|
object: 'domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
setConfigValue as saveConfigValue,
|
||||||
|
} from './storage-config-repo';
|
||||||
|
|
||||||
|
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
|
||||||
|
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
|
||||||
|
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
|
||||||
|
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
|
||||||
|
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
|
||||||
|
const PUSH_REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
interface CachedPushAccessToken {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedPushAccessToken: CachedPushAccessToken | null = null;
|
||||||
|
|
||||||
|
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...init, signal: controller.signal });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(errorMessage, error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomInstallationEmail(): string {
|
||||||
|
const bytes = new Uint8Array(10);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
|
||||||
|
return `${localPart}@nodewarden.app`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
|
||||||
|
return typeof row?.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||||
|
const [id, key] = await Promise.all([
|
||||||
|
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
|
||||||
|
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
|
||||||
|
]);
|
||||||
|
const normalizedId = String(id || '').trim();
|
||||||
|
const normalizedKey = String(key || '').trim();
|
||||||
|
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
|
||||||
|
const existing = await getPushInstallationCredentials(db);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
INSTALLATIONS_URI,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: randomInstallationEmail(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'Failed to request Bitwarden push installation:'
|
||||||
|
);
|
||||||
|
if (!response) return null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
|
||||||
|
const id = String(body?.id || body?.Id || '').trim();
|
||||||
|
const key = String(body?.key || body?.Key || '').trim();
|
||||||
|
if (!id || !key) {
|
||||||
|
console.error('Bitwarden push installation response did not include id/key');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
|
||||||
|
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
|
||||||
|
]);
|
||||||
|
return { id, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPushAccessToken(env: Env): Promise<string | null> {
|
||||||
|
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||||
|
if (!credentials) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
|
||||||
|
return cachedPushAccessToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'api.push',
|
||||||
|
client_id: `installation.${credentials.id}`,
|
||||||
|
client_secret: credentials.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
`${PUSH_IDENTITY_URI}/connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
},
|
||||||
|
'Failed to get Bitwarden push relay token:'
|
||||||
|
);
|
||||||
|
if (!response) return null;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
|
||||||
|
const token = String(body?.access_token || '').trim();
|
||||||
|
if (!token) {
|
||||||
|
console.error('Bitwarden push relay token response did not include an access_token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
|
||||||
|
cachedPushAccessToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: now + Math.floor(expiresInSeconds * 500),
|
||||||
|
};
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
|
||||||
|
const token = await getPushAccessToken(env);
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const response = await fetchPushEndpoint(
|
||||||
|
`${PUSH_RELAY_URI}${path}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
},
|
||||||
|
`Bitwarden push relay request failed: ${path}`
|
||||||
|
);
|
||||||
|
if (!response) return false;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||||||
|
const source = payload || {};
|
||||||
|
const id = source.Id ?? source.id;
|
||||||
|
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
|
||||||
|
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
|
||||||
|
|
||||||
|
if (id != null) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: source.UserId ?? source.userId ?? userId,
|
||||||
|
organizationId,
|
||||||
|
collectionIds,
|
||||||
|
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: source.UserId ?? source.userId ?? userId,
|
||||||
|
date: source.Date ?? source.date ?? revisionDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerMobilePushDevice(
|
||||||
|
env: Env,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
type: number;
|
||||||
|
pushUuid: string;
|
||||||
|
pushToken: string;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const credentials = await ensurePushInstallationCredentials(env.DB);
|
||||||
|
if (!credentials) return false;
|
||||||
|
|
||||||
|
return postToPushRelay(env, '/push/register', {
|
||||||
|
deviceId: input.pushUuid,
|
||||||
|
pushToken: input.pushToken,
|
||||||
|
userId: input.userId,
|
||||||
|
type: input.type,
|
||||||
|
identifier: input.deviceIdentifier,
|
||||||
|
installationId: credentials.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||||
|
const normalized = String(pushUuid || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyMobilePush(
|
||||||
|
env: Env,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
updateType: number;
|
||||||
|
revisionDate: string;
|
||||||
|
contextId: string | null;
|
||||||
|
payload: Record<string, unknown> | null | undefined;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const hasPushDevice = await env.DB
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||||
|
.bind(input.userId, '')
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
if (!hasPushDevice) return;
|
||||||
|
|
||||||
|
let actingPushUuid: string | null = null;
|
||||||
|
if (input.contextId) {
|
||||||
|
const row = await env.DB
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(input.userId, input.contextId)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
actingPushUuid = row?.push_uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postToPushRelay(env, '/push/send', {
|
||||||
|
userId: input.userId,
|
||||||
|
organizationId: null,
|
||||||
|
deviceId: actingPushUuid,
|
||||||
|
identifier: input.contextId,
|
||||||
|
type: input.updateType,
|
||||||
|
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
|
||||||
|
clientType: null,
|
||||||
|
installationId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
// Rate limiting service.
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||||
|
|
||||||
|
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
|
||||||
|
let accountPasskeySchemaReady = false;
|
||||||
|
|
||||||
|
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||||
|
{ name: 'id', sql: 'id TEXT' },
|
||||||
|
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||||
|
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
|
||||||
|
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||||
|
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||||
|
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||||
|
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
|
||||||
|
{ name: 'type', sql: 'type TEXT' },
|
||||||
|
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
|
||||||
|
{ name: 'transports', sql: 'transports TEXT' },
|
||||||
|
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
|
||||||
|
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
|
||||||
|
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
|
||||||
|
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
|
||||||
|
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
|
||||||
|
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
|
||||||
|
'challenge_hash',
|
||||||
|
'scope',
|
||||||
|
'user_id',
|
||||||
|
'expires_at',
|
||||||
|
'used_at',
|
||||||
|
'created_at',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
|
||||||
|
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
|
||||||
|
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||||
|
if (accountPasskeySchemaReady) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||||
|
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||||
|
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||||
|
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
|
||||||
|
if (!credentialColumns.has(column.name)) {
|
||||||
|
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
credentialColumns = await tableColumns(db, 'webauthn_credentials');
|
||||||
|
if (!credentialColumns.has('credential_id')) {
|
||||||
|
throw new Error('webauthn_credentials schema is missing credential_id');
|
||||||
|
}
|
||||||
|
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
|
||||||
|
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
|
||||||
|
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
|
||||||
|
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||||
|
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
|
||||||
|
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
|
||||||
|
if (!challengeSchemaComplete) {
|
||||||
|
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE webauthn_challenges (' +
|
||||||
|
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
|
||||||
|
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
|
||||||
|
|
||||||
|
accountPasskeySchemaReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTransports(value: string | null): string[] | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCredentialRow(row: {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
purpose?: string | null;
|
||||||
|
name: string;
|
||||||
|
public_key: string;
|
||||||
|
credential_id: string;
|
||||||
|
counter: number;
|
||||||
|
type: string | null;
|
||||||
|
aa_guid: string | null;
|
||||||
|
transports: string | null;
|
||||||
|
encrypted_user_key: string | null;
|
||||||
|
encrypted_public_key: string | null;
|
||||||
|
encrypted_private_key: string | null;
|
||||||
|
supports_prf: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}): AccountPasskeyCredential {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||||
|
name: row.name,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
credentialId: row.credential_id,
|
||||||
|
counter: Number(row.counter || 0),
|
||||||
|
type: row.type ?? null,
|
||||||
|
aaGuid: row.aa_guid ?? null,
|
||||||
|
transports: parseTransports(row.transports),
|
||||||
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
supportsPrf: !!row.supports_prf,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChallengeRow(row: {
|
||||||
|
challenge_hash: string;
|
||||||
|
scope: AccountPasskeyChallengeScope;
|
||||||
|
user_id: string | null;
|
||||||
|
expires_at: number;
|
||||||
|
used_at: number | null;
|
||||||
|
created_at: number;
|
||||||
|
}): AccountPasskeyChallenge {
|
||||||
|
return {
|
||||||
|
challengeHash: row.challenge_hash,
|
||||||
|
scope: row.scope,
|
||||||
|
userId: row.user_id ?? null,
|
||||||
|
expiresAt: Number(row.expires_at || 0),
|
||||||
|
usedAt: row.used_at == null ? null : Number(row.used_at),
|
||||||
|
createdAt: Number(row.created_at || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAccountPasskeyCredential(
|
||||||
|
db: D1Database,
|
||||||
|
safeBind: SafeBindFn,
|
||||||
|
credential: AccountPasskeyCredential
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
await safeBind(
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO webauthn_credentials(' +
|
||||||
|
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||||
|
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||||
|
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||||
|
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||||
|
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||||
|
),
|
||||||
|
credential.id,
|
||||||
|
credential.userId,
|
||||||
|
credential.purpose,
|
||||||
|
credential.name,
|
||||||
|
credential.publicKey,
|
||||||
|
credential.credentialId,
|
||||||
|
credential.counter,
|
||||||
|
credential.type,
|
||||||
|
credential.aaGuid,
|
||||||
|
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||||
|
credential.encryptedUserKey,
|
||||||
|
credential.encryptedPublicKey,
|
||||||
|
credential.encryptedPrivateKey,
|
||||||
|
credential.supportsPrf ? 1 : 0,
|
||||||
|
credential.createdAt,
|
||||||
|
credential.updatedAt
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccountPasskeyCredentialsByUserId(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const rows = await db
|
||||||
|
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||||
|
.bind(userId, purpose)
|
||||||
|
.all<any>();
|
||||||
|
return (rows.results || []).map(mapCredentialRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountPasskeyCredentialById(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<AccountPasskeyCredential | null> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
|
||||||
|
.bind(userId, id)
|
||||||
|
.first<any>();
|
||||||
|
return row ? mapCredentialRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountPasskeyCredentialByCredentialId(
|
||||||
|
db: D1Database,
|
||||||
|
credentialId: string
|
||||||
|
): Promise<AccountPasskeyCredential | null> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
|
||||||
|
.bind(credentialId)
|
||||||
|
.first<any>();
|
||||||
|
return row ? mapCredentialRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countAccountPasskeyCredentialsByUserId(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<number> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||||
|
.bind(userId, purpose)
|
||||||
|
.first<{ count: number }>();
|
||||||
|
return Number(row?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccountPasskeyCounter(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
credentialId: string,
|
||||||
|
counter: number,
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
|
||||||
|
.bind(counter, updatedAt, userId, credentialId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccountPasskeyEncryption(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
credentialId: string,
|
||||||
|
encryptedUserKey: string,
|
||||||
|
encryptedPublicKey: string,
|
||||||
|
encryptedPrivateKey: string,
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
||||||
|
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
|
||||||
|
)
|
||||||
|
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccountPasskeyCredential(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<boolean> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||||
|
.bind(userId, id, purpose)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAccountPasskeyChallenge(
|
||||||
|
db: D1Database,
|
||||||
|
challenge: AccountPasskeyChallenge
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
challenge.challengeHash,
|
||||||
|
challenge.scope,
|
||||||
|
challenge.userId,
|
||||||
|
challenge.expiresAt,
|
||||||
|
challenge.usedAt,
|
||||||
|
challenge.createdAt
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeAccountPasskeyChallenge(
|
||||||
|
db: D1Database,
|
||||||
|
challengeHash: string,
|
||||||
|
scope: AccountPasskeyChallengeScope,
|
||||||
|
userId: string | null,
|
||||||
|
nowMs: number
|
||||||
|
): Promise<AccountPasskeyChallenge | null> {
|
||||||
|
await ensureAccountPasskeySchema(db);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
|
||||||
|
.bind(challengeHash, scope)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
const challenge = mapChallengeRow(row);
|
||||||
|
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
|
||||||
|
if (userId !== null && challenge.userId !== userId) return null;
|
||||||
|
if (userId === null && challenge.userId !== null) return null;
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
|
||||||
|
.bind(nowMs, challengeHash)
|
||||||
|
.run();
|
||||||
|
if (Number(result.meta.changes || 0) <= 0) return null;
|
||||||
|
return { ...challenge, usedAt: nowMs };
|
||||||
|
}
|
||||||
@@ -1,5 +1,72 @@
|
|||||||
import type { AuditLog, Invite } from '../types';
|
import type { AuditLog, Invite } from '../types';
|
||||||
|
|
||||||
|
export interface AuditLogListOptions {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
category?: string | null;
|
||||||
|
level?: string | null;
|
||||||
|
q?: string | null;
|
||||||
|
from?: string | null;
|
||||||
|
to?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListResult {
|
||||||
|
logs: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditLogFromRow(row: any): AuditLog {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
actorUserId: row.actor_user_id ?? null,
|
||||||
|
actorEmail: row.actor_email ?? null,
|
||||||
|
action: row.action,
|
||||||
|
category: row.category || 'system',
|
||||||
|
level: row.level || 'info',
|
||||||
|
targetType: row.target_type ?? null,
|
||||||
|
targetId: row.target_id ?? null,
|
||||||
|
targetUserEmail: row.target_user_email ?? null,
|
||||||
|
metadata: row.metadata ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (options.from) {
|
||||||
|
conditions.push('l.created_at >= ?');
|
||||||
|
params.push(options.from);
|
||||||
|
}
|
||||||
|
if (options.to) {
|
||||||
|
conditions.push('l.created_at <= ?');
|
||||||
|
params.push(options.to);
|
||||||
|
}
|
||||||
|
if (options.category) {
|
||||||
|
conditions.push('l.category = ?');
|
||||||
|
params.push(options.category);
|
||||||
|
}
|
||||||
|
if (options.level) {
|
||||||
|
conditions.push('l.level = ?');
|
||||||
|
params.push(options.level);
|
||||||
|
}
|
||||||
|
if (options.q) {
|
||||||
|
const q = options.q.toLowerCase().slice(0, 48);
|
||||||
|
const like = `%${q}%`;
|
||||||
|
conditions.push(
|
||||||
|
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
|
||||||
|
);
|
||||||
|
params.push(like, like, like, like, like, like);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -50,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
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);
|
||||||
@@ -77,8 +176,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
|
|||||||
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
|
||||||
|
.bind(beforeIso)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
|
||||||
|
const limit = Math.max(1, Math.floor(maxEntries));
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM audit_logs WHERE id IN (' +
|
||||||
|
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAuditLogs(db: D1Database): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM audit_logs').run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
|
||||||
|
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
|
||||||
|
const offset = Math.max(0, Math.floor(options.offset || 0));
|
||||||
|
const { where, params } = buildAuditWhere(options);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
|
||||||
|
'FROM audit_logs l ' +
|
||||||
|
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
|
||||||
|
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
|
||||||
|
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.bind(...params, limit + 1, offset)
|
||||||
|
.all<any>();
|
||||||
|
const results = rows.results || [];
|
||||||
|
const logs = results.slice(0, limit).map(auditLogFromRow);
|
||||||
|
const hasMore = results.length > limit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
total: offset + logs.length + (hasMore ? 1 : 0),
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||||
|
WHERE a.id = ? AND c.user_id = ?`
|
||||||
|
)
|
||||||
|
.bind(id, userId)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
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,36 @@ 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(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
attachmentIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||||
|
if (!uniqueIds.length) return;
|
||||||
|
const chunkSize = sqlChunkSize(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
@@ -119,9 +174,28 @@ 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 removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
export async function addAttachmentToCipherForUser(
|
||||||
void cipherId;
|
db: D1Database,
|
||||||
void attachmentId;
|
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> {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { AuthRequestRecord, AuthRequestType } from '../types';
|
||||||
|
|
||||||
|
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function mapAuthRequestRow(row: any): AuthRequestRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
organizationId: row.organization_id ?? null,
|
||||||
|
type: Number(row.type) as AuthRequestType,
|
||||||
|
requestDeviceIdentifier: row.request_device_identifier,
|
||||||
|
requestDeviceType: Number(row.request_device_type ?? 14),
|
||||||
|
requestIpAddress: row.request_ip_address ?? null,
|
||||||
|
requestCountryName: row.request_country_name ?? null,
|
||||||
|
responseDeviceIdentifier: row.response_device_identifier ?? null,
|
||||||
|
accessCode: row.access_code,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
key: row.key ?? null,
|
||||||
|
masterPasswordHash: row.master_password_hash ?? null,
|
||||||
|
approved: row.approved == null ? null : Number(row.approved) === 1,
|
||||||
|
creationDate: row.creation_date,
|
||||||
|
responseDate: row.response_date ?? null,
|
||||||
|
authenticationDate: row.authentication_date ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
|
||||||
|
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_REQUEST_SELECT =
|
||||||
|
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||||
|
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
|
||||||
|
'FROM auth_requests';
|
||||||
|
|
||||||
|
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO auth_requests(' +
|
||||||
|
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||||
|
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
|
||||||
|
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
request.id,
|
||||||
|
request.userId,
|
||||||
|
request.organizationId,
|
||||||
|
request.type,
|
||||||
|
request.requestDeviceIdentifier,
|
||||||
|
request.requestDeviceType,
|
||||||
|
request.requestIpAddress,
|
||||||
|
request.requestCountryName,
|
||||||
|
request.responseDeviceIdentifier,
|
||||||
|
request.accessCode,
|
||||||
|
request.publicKey,
|
||||||
|
request.key,
|
||||||
|
request.masterPasswordHash,
|
||||||
|
request.approved == null ? null : (request.approved ? 1 : 0),
|
||||||
|
request.creationDate,
|
||||||
|
request.responseDate,
|
||||||
|
request.authenticationDate
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
|
||||||
|
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
|
||||||
|
return row ? mapAuthRequestRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||||
|
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
|
||||||
|
return row ? mapAuthRequestRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
|
||||||
|
return (res.results || []).map(mapAuthRequestRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
|
||||||
|
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
|
||||||
|
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
|
||||||
|
'FROM auth_requests ar ' +
|
||||||
|
'JOIN (' +
|
||||||
|
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
|
||||||
|
' FROM auth_requests ' +
|
||||||
|
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
|
||||||
|
' GROUP BY request_device_identifier' +
|
||||||
|
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
|
||||||
|
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
|
||||||
|
'ORDER BY ar.creation_date DESC'
|
||||||
|
)
|
||||||
|
.bind(userId, cutoff, userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAuthRequestResponse(
|
||||||
|
db: D1Database,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
update: {
|
||||||
|
approved: boolean;
|
||||||
|
responseDeviceIdentifier: string;
|
||||||
|
key?: string | null;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
responseDate?: string;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
|
||||||
|
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
update.approved ? 1 : 0,
|
||||||
|
update.responseDeviceIdentifier,
|
||||||
|
update.approved ? (update.key ?? null) : null,
|
||||||
|
update.approved ? (update.masterPasswordHash ?? null) : null,
|
||||||
|
update.responseDate || new Date().toISOString(),
|
||||||
|
id,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
|
||||||
|
.bind(authenticationDate, id)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
|
||||||
|
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||||
|
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
@@ -27,6 +27,47 @@ interface CipherRow {
|
|||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||||
|
'id',
|
||||||
|
'userId',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'folderId',
|
||||||
|
'folder_id',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'favorite',
|
||||||
|
'reprompt',
|
||||||
|
'key',
|
||||||
|
'attachments',
|
||||||
|
'Attachments',
|
||||||
|
'attachments2',
|
||||||
|
'Attachments2',
|
||||||
|
'createdAt',
|
||||||
|
'created_at',
|
||||||
|
'creationDate',
|
||||||
|
'updatedAt',
|
||||||
|
'updated_at',
|
||||||
|
'revisionDate',
|
||||||
|
'archivedAt',
|
||||||
|
'archived_at',
|
||||||
|
'archivedDate',
|
||||||
|
'deletedAt',
|
||||||
|
'deleted_at',
|
||||||
|
'deletedDate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function buildCipherData(cipher: Cipher, folderId: string | null): string {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
...cipher,
|
||||||
|
folderId,
|
||||||
|
};
|
||||||
|
for (const key of CIPHER_SCALAR_DATA_KEYS) {
|
||||||
|
delete payload[key];
|
||||||
|
}
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||||
if (!row?.data) return null;
|
if (!row?.data) return null;
|
||||||
try {
|
try {
|
||||||
@@ -46,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||||
deletedAt: row.deleted_at ?? null,
|
deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Corrupted cipher data, id:', row.id);
|
console.error('Corrupted cipher data, id:', row.id);
|
||||||
@@ -66,17 +107,23 @@ 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 = JSON.stringify({
|
const data = buildCipherData(cipher, folderId);
|
||||||
...cipher,
|
|
||||||
folderId,
|
|
||||||
});
|
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'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,
|
||||||
@@ -117,8 +164,7 @@ export async function bulkSoftDeleteCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -126,10 +172,11 @@ export async function bulkSoftDeleteCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
SET deleted_at = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, now, patch, userId, ...chunk)
|
.bind(now, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +195,7 @@ export async function bulkRestoreCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
const chunkSize = sqlChunkSize(2);
|
||||||
const chunkSize = sqlChunkSize(3);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -157,10 +203,11 @@ export async function bulkRestoreCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
SET deleted_at = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, patch, userId, ...chunk)
|
.bind(now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +253,9 @@ export async function getCiphersPage(
|
|||||||
limit: number,
|
limit: number,
|
||||||
offset: number
|
offset: number
|
||||||
): Promise<Cipher[]> {
|
): Promise<Cipher[]> {
|
||||||
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
const whereDeleted = includeDeleted
|
||||||
|
? ''
|
||||||
|
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${selectCipherColumns()} FROM ciphers
|
`SELECT ${selectCipherColumns()} FROM ciphers
|
||||||
@@ -262,8 +311,7 @@ export async function bulkMoveCiphers(
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const normalizedFolderId = normalizeOptionalId(folderId);
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
const uniqueIds = sanitizeIds(ids);
|
const uniqueIds = sanitizeIds(ids);
|
||||||
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -271,10 +319,11 @@ export async function bulkMoveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
SET folder_id = ?, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
.bind(normalizedFolderId, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +342,7 @@ export async function bulkArchiveCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
const chunkSize = sqlChunkSize(3);
|
||||||
const chunkSize = sqlChunkSize(4);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -302,10 +350,14 @@ export async function bulkArchiveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
SET archived_at = ?, updated_at = ?,
|
||||||
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND json_extract(data, '$.deletedAt') IS NULL
|
||||||
|
AND json_extract(data, '$.deletedDate') IS NULL`
|
||||||
)
|
)
|
||||||
.bind(now, now, patch, userId, ...chunk)
|
.bind(now, now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +376,7 @@ export async function bulkUnarchiveCiphers(
|
|||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
const chunkSize = sqlChunkSize(2);
|
||||||
const chunkSize = sqlChunkSize(3);
|
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
@@ -333,10 +384,11 @@ export async function bulkUnarchiveCiphers(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
SET archived_at = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(now, patch, userId, ...chunk)
|
.bind(now, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
type GetUserByEmail = (email: string) => Promise<User | null>;
|
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||||
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||||
@@ -8,11 +9,15 @@ function mapDeviceRow(row: any): Device {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
deviceIdentifier: row.device_identifier,
|
deviceIdentifier: row.device_identifier,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
deviceNote: row.device_note ?? null,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
sessionStamp: row.session_stamp || '',
|
sessionStamp: row.session_stamp || '',
|
||||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
pushUuid: row.push_uuid ?? null,
|
||||||
|
pushToken: row.push_token ?? null,
|
||||||
|
lastSeenAt: row.last_seen_at ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -33,31 +38,81 @@ export async function upsertDevice(
|
|||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||||
|
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||||
|
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_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), ' +
|
||||||
|
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
|
||||||
|
'last_seen_at=excluded.last_seen_at, ' +
|
||||||
'updated_at=excluded.updated_at'
|
'updated_at=excluded.updated_at'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
userId,
|
userId,
|
||||||
deviceIdentifier,
|
deviceIdentifier,
|
||||||
name,
|
effectiveName,
|
||||||
type,
|
type,
|
||||||
effectiveSessionStamp,
|
effectiveSessionStamp,
|
||||||
keys?.encryptedUserKey ?? null,
|
keys?.encryptedUserKey ?? null,
|
||||||
keys?.encryptedPublicKey ?? null,
|
keys?.encryptedPublicKey ?? null,
|
||||||
keys?.encryptedPrivateKey ?? null,
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
effectivePushUuid,
|
||||||
|
existingDevice?.deviceNote ?? null,
|
||||||
now,
|
now,
|
||||||
now
|
now,
|
||||||
|
now,
|
||||||
|
''
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceName(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(String(name || '').trim(), userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchDeviceLastSeen(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(now, userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
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,
|
||||||
@@ -133,8 +188,8 @@ export async function isKnownDeviceByEmail(
|
|||||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||||
)
|
)
|
||||||
.bind(userId)
|
.bind(userId)
|
||||||
.all<any>();
|
.all<any>();
|
||||||
@@ -144,7 +199,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
|||||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier)
|
.bind(userId, deviceIdentifier)
|
||||||
@@ -152,6 +207,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
|
|||||||
return row ? mapDeviceRow(row) : null;
|
return row ? mapDeviceRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDevicePushToken(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
pushUuid: string,
|
||||||
|
pushToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
|
||||||
|
'WHERE user_id = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDevicePushToken(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<{ pushUuid: string | null } | null> {
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
if (!existing) return null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(new Date().toISOString(), userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { pushUuid: existing.push_uuid ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevicePushUuid(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ push_uuid: string | null }>();
|
||||||
|
return row?.push_uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
|
||||||
|
.bind(userId, '')
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||||
@@ -200,6 +312,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
|
|||||||
return Number(result.meta.changes ?? 0);
|
return Number(result.meta.changes ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTrustedTwoFactorTokensExpiryByDevice(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
expiresAtMs: number
|
||||||
|
): Promise<number> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
|
||||||
|
.bind(expiresAtMs, userId, deviceIdentifier, now)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveTrustedTwoFactorDeviceToken(
|
export async function saveTrustedTwoFactorDeviceToken(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
trustedTokenKey: TrustedTokenKeyFn,
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { UserDomainSettings } from '../types';
|
||||||
|
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||||
|
|
||||||
|
// Storage adapter for the domain_settings table.
|
||||||
|
//
|
||||||
|
// CONTRACT:
|
||||||
|
// equivalent_domains is kept as the active derived groups for compatibility and
|
||||||
|
// fallback reads. custom_equivalent_domains is the full rule list that preserves
|
||||||
|
// UI/client state. Save both together through saveUserDomainSettings().
|
||||||
|
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{
|
||||||
|
equivalent_domains: string | null;
|
||||||
|
custom_equivalent_domains: string | null;
|
||||||
|
excluded_global_equivalent_domains: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}>();
|
||||||
|
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||||
|
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||||
|
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||||
|
: [];
|
||||||
|
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||||
|
? storedCustomEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||||
|
updatedAt: row?.updated_at || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserDomainSettings(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||||
|
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||||
|
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||||
|
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||||
|
'updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
JSON.stringify(equivalentDomains),
|
||||||
|
JSON.stringify(customEquivalentDomains),
|
||||||
|
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||||
|
updatedAt
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Cipher, Folder } from '../types';
|
import type { Folder } from '../types';
|
||||||
|
|
||||||
function mapFolderRow(row: any): Folder {
|
function mapFolderRow(row: any): Folder {
|
||||||
return {
|
return {
|
||||||
@@ -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();
|
||||||
@@ -36,67 +45,64 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
|
|||||||
export async function clearFolderFromCiphers(
|
export async function clearFolderFromCiphers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
folderId: string,
|
folderId: string
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const res = await db
|
await db
|
||||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
.prepare(
|
||||||
.bind(userId, folderId)
|
`UPDATE ciphers
|
||||||
.all<{ data: string }>();
|
SET folder_id = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
for (const row of (res.results || [])) {
|
WHERE user_id = ?
|
||||||
let cipher: Cipher;
|
AND (
|
||||||
try {
|
folder_id = ?
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
OR json_extract(data, '$.folderId') = ?
|
||||||
} catch {
|
OR json_extract(data, '$.folder_id') = ?
|
||||||
continue;
|
)`
|
||||||
}
|
)
|
||||||
cipher.folderId = null;
|
.bind(now, userId, folderId, folderId, folderId)
|
||||||
cipher.updatedAt = now;
|
.run();
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDeleteFolders(
|
export async function bulkDeleteFolders(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
ids: string[],
|
ids: string[],
|
||||||
sqlChunkSize: (fixedBindCount: number) => number,
|
sqlChunkSize: (fixedBindCount: number, bindCountPerItem?: number) => number,
|
||||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
|
||||||
updateRevisionDate: (userId: string) => Promise<string>
|
updateRevisionDate: (userId: string) => Promise<string>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
if (!uniqueIds.length) return null;
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
const chunkSize = sqlChunkSize(1);
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// Each folder ID is bound in all three compatibility predicates below.
|
||||||
|
const chunkSize = sqlChunkSize(2, 3);
|
||||||
|
const statements: D1PreparedStatement[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
const placeholders = chunk.map(() => '?').join(',');
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
const res = await db
|
statements.push(
|
||||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
db.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET folder_id = NULL, updated_at = ?,
|
||||||
|
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (
|
||||||
|
folder_id IN (${placeholders})
|
||||||
|
OR json_extract(data, '$.folderId') IN (${placeholders})
|
||||||
|
OR json_extract(data, '$.folder_id') IN (${placeholders})
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.bind(now, userId, ...chunk, ...chunk, ...chunk)
|
||||||
|
);
|
||||||
|
statements.push(
|
||||||
|
db.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||||
.bind(userId, ...chunk)
|
.bind(userId, ...chunk)
|
||||||
.all<{ data: string }>();
|
);
|
||||||
|
|
||||||
for (const row of res.results || []) {
|
|
||||||
let cipher: Cipher;
|
|
||||||
try {
|
|
||||||
cipher = JSON.parse(row.data) as Cipher;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
cipher.folderId = null;
|
|
||||||
cipher.updatedAt = now;
|
|
||||||
await saveCipher(cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.batch(statements);
|
||||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
|
||||||
.bind(userId, ...chunk)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateRevisionDate(userId);
|
return updateRevisionDate(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,13 +46,6 @@ export async function getRefreshTokenRecord(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
refreshTokenKey: RefreshTokenKeyFn,
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
saveRefreshTokenRecord: (
|
|
||||||
token: string,
|
|
||||||
userId: string,
|
|
||||||
expiresAtMs?: number,
|
|
||||||
deviceIdentifier?: string | null,
|
|
||||||
deviceSessionStamp?: string | null
|
|
||||||
) => Promise<void>,
|
|
||||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||||
token: string
|
token: string
|
||||||
): Promise<RefreshTokenRecord | null> {
|
): Promise<RefreshTokenRecord | null> {
|
||||||
@@ -42,41 +53,26 @@ export async function getRefreshTokenRecord(
|
|||||||
await maybeCleanupExpiredRefreshTokens(now);
|
await maybeCleanupExpiredRefreshTokens(now);
|
||||||
const tokenKey = await refreshTokenKey(token);
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
let row = await db
|
const row = await db
|
||||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
.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;
|
||||||
if (!row) {
|
expires_at: number;
|
||||||
const legacyRow = await db
|
device_identifier: string | null;
|
||||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
device_session_stamp: string | null;
|
||||||
.bind(token)
|
security_stamp: string | null;
|
||||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
created_at: number | null;
|
||||||
|
last_used_at: number | null;
|
||||||
if (legacyRow) {
|
absolute_expires_at: number | null;
|
||||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
client_type: string | null;
|
||||||
await deleteRefreshTokenRecord(token);
|
}>();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await saveRefreshTokenRecord(
|
|
||||||
token,
|
|
||||||
legacyRow.user_id,
|
|
||||||
legacyRow.expires_at,
|
|
||||||
legacyRow.device_identifier ?? null,
|
|
||||||
legacyRow.device_session_stamp ?? null
|
|
||||||
);
|
|
||||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
|
||||||
return {
|
|
||||||
userId: legacyRow.user_id,
|
|
||||||
expiresAt: legacyRow.expires_at,
|
|
||||||
deviceIdentifier: legacyRow.device_identifier ?? null,
|
|
||||||
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) 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;
|
||||||
}
|
}
|
||||||
@@ -85,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();
|
||||||
@@ -106,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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
// This is the runtime D1 schema bootstrap. Keep it in sync with
|
||||||
// Any new table/column/index must be added to both places together.
|
// migrations/0001_init.sql. Any new table/column/index must be added to both
|
||||||
|
// places together.
|
||||||
|
//
|
||||||
|
// WHEN CHANGING THIS:
|
||||||
|
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
|
||||||
|
// rerun these idempotent statements.
|
||||||
|
// - If the new table stores persistent data, update the backup export/import
|
||||||
|
// contract in src/services/backup-archive.ts and backup-import.ts.
|
||||||
|
// - Keep statements idempotent; D1 may execute them again on later requests.
|
||||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
'CREATE TABLE IF NOT EXISTS users (' +
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'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, 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',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
@@ -27,6 +47,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
@@ -47,31 +69,48 @@ 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_sends_user_updated ON sends(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
'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)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
||||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
|
||||||
|
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
|
||||||
|
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
|
||||||
|
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
@@ -80,18 +119,51 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN push_token TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
|
||||||
|
"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 (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||||
|
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
|
||||||
|
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 api_rate_limits (' +
|
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
|
||||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
|
||||||
'PRIMARY KEY (identifier, window_start))',
|
'PRIMARY KEY (user_id, time_counter), ' +
|
||||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||||
|
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
|
||||||
|
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|||||||
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
|
|||||||
return mapSendRow(row);
|
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, 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,16 @@ 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,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -64,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, 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, 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,
|
||||||
@@ -90,6 +97,13 @@ 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.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
@@ -102,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, 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(
|
||||||
@@ -126,6 +140,13 @@ 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.createdAt,
|
user.createdAt,
|
||||||
user.updatedAt
|
user.updatedAt
|
||||||
).run();
|
).run();
|
||||||
|
|||||||
+364
-23
@@ -1,5 +1,6 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { ensurePushInstallationCredentials } from './push-relay';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
getConfigValue as getStoredConfigValue,
|
getConfigValue as getStoredConfigValue,
|
||||||
@@ -18,13 +19,21 @@ import {
|
|||||||
saveUser as saveStoredUser,
|
saveUser as saveStoredUser,
|
||||||
} from './storage-user-repo';
|
} from './storage-user-repo';
|
||||||
import {
|
import {
|
||||||
|
type AuditLogListOptions,
|
||||||
createAuditLog as createStoredAuditLog,
|
createAuditLog as createStoredAuditLog,
|
||||||
|
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,
|
||||||
listInvites as listStoredInvites,
|
listInvites as listStoredInvites,
|
||||||
markInviteUsed as markStoredInviteUsed,
|
markInviteUsed as markStoredInviteUsed,
|
||||||
revokeInvite as revokeStoredInvite,
|
pruneAuditLogs as pruneStoredAuditLogs,
|
||||||
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||||
|
revertInviteUsed as revertStoredInviteUsed,
|
||||||
} from './storage-admin-repo';
|
} from './storage-admin-repo';
|
||||||
import {
|
import {
|
||||||
bulkDeleteFolders as deleteStoredFolders,
|
bulkDeleteFolders as deleteStoredFolders,
|
||||||
@@ -32,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';
|
||||||
@@ -44,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,
|
||||||
@@ -51,13 +62,16 @@ import {
|
|||||||
} from './storage-cipher-repo';
|
} from './storage-cipher-repo';
|
||||||
import {
|
import {
|
||||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||||
|
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
|
||||||
|
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,
|
||||||
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
|
|
||||||
saveAttachment as saveStoredAttachment,
|
saveAttachment as saveStoredAttachment,
|
||||||
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
||||||
} from './storage-attachment-repo';
|
} from './storage-attachment-repo';
|
||||||
@@ -66,47 +80,92 @@ 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';
|
||||||
import {
|
import {
|
||||||
deleteDevice as deleteStoredDevice,
|
deleteDevice as deleteStoredDevice,
|
||||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||||
|
clearDevicePushToken as clearStoredDevicePushToken,
|
||||||
clearDeviceKeys as clearStoredDeviceKeys,
|
clearDeviceKeys as clearStoredDeviceKeys,
|
||||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||||
getDevice as findStoredDevice,
|
getDevice as findStoredDevice,
|
||||||
|
getDevicePushUuid as findStoredDevicePushUuid,
|
||||||
getDevicesByUserId as listStoredDevicesByUserId,
|
getDevicesByUserId as listStoredDevicesByUserId,
|
||||||
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
||||||
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
||||||
isKnownDevice as getKnownStoredDevice,
|
isKnownDevice as getKnownStoredDevice,
|
||||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||||
|
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
|
||||||
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
|
updateDeviceName as updateStoredDeviceName,
|
||||||
updateDeviceKeys as updateStoredDeviceKeys,
|
updateDeviceKeys as updateStoredDeviceKeys,
|
||||||
|
updateDevicePushToken as updateStoredDevicePushToken,
|
||||||
|
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||||
|
userHasPushDevice as getUserHasPushDevice,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
|
import {
|
||||||
|
createAuthRequest as createStoredAuthRequest,
|
||||||
|
getAuthRequestById as findStoredAuthRequestById,
|
||||||
|
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
|
||||||
|
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||||
|
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||||
|
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||||
|
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
|
||||||
|
updateAuthRequestResponse as updateStoredAuthRequestResponse,
|
||||||
|
} from './storage-auth-request-repo';
|
||||||
import {
|
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,
|
||||||
} from './storage-revision-repo';
|
} from './storage-revision-repo';
|
||||||
|
import {
|
||||||
|
getUserDomainSettings as getStoredUserDomainSettings,
|
||||||
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||||
|
} from './storage-domain-rules-repo';
|
||||||
|
import {
|
||||||
|
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
|
||||||
|
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
|
||||||
|
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
|
||||||
|
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
|
||||||
|
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
|
||||||
|
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
|
||||||
|
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
|
||||||
|
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
|
||||||
|
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
|
||||||
|
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
|
||||||
|
} from './storage-account-passkey-repo';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
// IMPORTANT:
|
||||||
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
|
// differs from config.schema.version.
|
||||||
|
const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
|
||||||
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -119,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) {}
|
||||||
@@ -137,10 +199,25 @@ export class StorageService {
|
|||||||
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sqlChunkSize(fixedBindCount: number): number {
|
private async hasRequiredSchemaTables(): Promise<boolean> {
|
||||||
|
const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
|
||||||
|
const result = await this.db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
||||||
|
.bind(...REQUIRED_SCHEMA_TABLES)
|
||||||
|
.all<{ name: string }>();
|
||||||
|
const found = new Set((result.results || []).map((row) => row.name));
|
||||||
|
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sqlChunkSize(fixedBindCount: number, bindCountPerItem = 1): number {
|
||||||
|
const safeFixedBindCount = Math.max(0, Math.floor(fixedBindCount));
|
||||||
|
const safeBindCountPerItem = Math.max(1, Math.floor(bindCountPerItem));
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
|
Math.min(
|
||||||
|
LIMITS.performance.bulkMoveChunkSize,
|
||||||
|
Math.floor((StorageService.MAX_D1_SQL_VARIABLES - safeFixedBindCount) / safeBindCountPerItem)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +257,14 @@ export class StorageService {
|
|||||||
|
|
||||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||||
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
||||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
|
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
|
||||||
|
? !(await this.hasRequiredSchemaTables())
|
||||||
|
: true;
|
||||||
|
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
|
||||||
await ensureStorageSchema(this.db);
|
await ensureStorageSchema(this.db);
|
||||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
await ensurePushInstallationCredentials(this.db);
|
||||||
|
|
||||||
StorageService.schemaVerified = true;
|
StorageService.schemaVerified = true;
|
||||||
}
|
}
|
||||||
@@ -256,8 +337,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> {
|
||||||
@@ -268,12 +361,132 @@ export class StorageService {
|
|||||||
await createStoredAuditLog(this.db, log);
|
await createStoredAuditLog(this.db, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
|
||||||
|
return listStoredAuditLogs(this.db, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneAuditLogs(beforeIso: string): Promise<number> {
|
||||||
|
return pruneStoredAuditLogs(this.db, beforeIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
|
||||||
|
return pruneStoredAuditLogsToMax(this.db, maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAuditLogs(): Promise<number> {
|
||||||
|
return clearStoredAuditLogs(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domain rules ---
|
||||||
|
|
||||||
|
async getUserDomainSettings(userId: string) {
|
||||||
|
return getStoredUserDomainSettings(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUserDomainSettings(
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
await saveStoredUserDomainSettings(
|
||||||
|
this.db,
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Account passkeys / WebAuthn login credentials ---
|
||||||
|
|
||||||
|
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
|
||||||
|
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountPasskeyCredentialsByUserId(
|
||||||
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<AccountPasskeyCredential[]> {
|
||||||
|
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||||
|
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
|
||||||
|
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countAccountPasskeyCredentialsByUserId(
|
||||||
|
userId: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<number> {
|
||||||
|
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountPasskeyCounter(
|
||||||
|
userId: string,
|
||||||
|
credentialId: string,
|
||||||
|
counter: number,
|
||||||
|
updatedAt: string = new Date().toISOString()
|
||||||
|
): Promise<void> {
|
||||||
|
await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccountPasskeyEncryption(
|
||||||
|
userId: string,
|
||||||
|
credentialId: string,
|
||||||
|
encryptedUserKey: string,
|
||||||
|
encryptedPublicKey: string,
|
||||||
|
encryptedPrivateKey: string,
|
||||||
|
updatedAt: string = new Date().toISOString()
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredAccountPasskeyEncryption(
|
||||||
|
this.db,
|
||||||
|
userId,
|
||||||
|
credentialId,
|
||||||
|
encryptedUserKey,
|
||||||
|
encryptedPublicKey,
|
||||||
|
encryptedPrivateKey,
|
||||||
|
updatedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccountPasskeyCredential(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||||
|
): Promise<boolean> {
|
||||||
|
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||||
|
await saveStoredAccountPasskeyChallenge(this.db, challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeAccountPasskeyChallenge(
|
||||||
|
challengeHash: string,
|
||||||
|
scope: AccountPasskeyChallengeScope,
|
||||||
|
userId: string | null,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): Promise<AccountPasskeyChallenge | null> {
|
||||||
|
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ciphers ---
|
// --- Ciphers ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -324,6 +537,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);
|
||||||
}
|
}
|
||||||
@@ -338,7 +555,6 @@ export class StorageService {
|
|||||||
userId,
|
userId,
|
||||||
ids,
|
ids,
|
||||||
this.sqlChunkSize.bind(this),
|
this.sqlChunkSize.bind(this),
|
||||||
this.saveCipher.bind(this),
|
|
||||||
this.updateRevisionDate.bind(this)
|
this.updateRevisionDate.bind(this)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -346,7 +562,7 @@ export class StorageService {
|
|||||||
// Clear folder references from all ciphers owned by the user.
|
// Clear folder references from all ciphers owned by the user.
|
||||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||||
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
|
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||||
@@ -363,6 +579,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);
|
||||||
}
|
}
|
||||||
@@ -371,6 +591,14 @@ 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> {
|
||||||
|
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||||
|
}
|
||||||
|
|
||||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
@@ -387,8 +615,8 @@ export class StorageService {
|
|||||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
|
||||||
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
|
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
@@ -411,9 +639,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),
|
||||||
@@ -422,7 +654,10 @@ export class StorageService {
|
|||||||
userId,
|
userId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
deviceIdentifier,
|
deviceIdentifier,
|
||||||
deviceSessionStamp
|
deviceSessionStamp,
|
||||||
|
securityStamp,
|
||||||
|
clientType,
|
||||||
|
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +666,6 @@ export class StorageService {
|
|||||||
this.db,
|
this.db,
|
||||||
this.refreshTokenKey.bind(this),
|
this.refreshTokenKey.bind(this),
|
||||||
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
||||||
this.saveRefreshToken.bind(this),
|
|
||||||
this.deleteRefreshToken.bind(this),
|
this.deleteRefreshToken.bind(this),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
@@ -452,6 +686,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);
|
||||||
}
|
}
|
||||||
@@ -493,11 +731,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> {
|
||||||
@@ -538,6 +781,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,
|
||||||
@@ -550,6 +797,35 @@ export class StorageService {
|
|||||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
||||||
|
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDevicePushToken(
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
pushUuid: string,
|
||||||
|
pushToken: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
|
||||||
|
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
|
||||||
|
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async userHasPushDevice(userId: string): Promise<boolean> {
|
||||||
|
return getUserHasPushDevice(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||||
}
|
}
|
||||||
@@ -562,6 +838,49 @@ export class StorageService {
|
|||||||
return deleteStoredDevicesByUserId(this.db, userId);
|
return deleteStoredDevicesByUserId(this.db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth requests / Login with device ---
|
||||||
|
|
||||||
|
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
|
||||||
|
await createStoredAuthRequest(this.db, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
|
||||||
|
return findStoredAuthRequestById(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||||
|
return findStoredAuthRequestByIdForUser(this.db, id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
return listStoredAuthRequestsByUserId(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
return listStoredPendingAuthRequestsByUserId(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAuthRequestResponse(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
update: {
|
||||||
|
approved: boolean;
|
||||||
|
responseDeviceIdentifier: string;
|
||||||
|
key?: string | null;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredAuthRequestResponse(this.db, id, userId, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
|
||||||
|
return markStoredAuthRequestAuthenticated(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneExpiredAuthRequests(): Promise<number> {
|
||||||
|
return pruneStoredExpiredAuthRequests(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||||
return listStoredTrustedTokenSummaries(this.db, userId);
|
return listStoredTrustedTokenSummaries(this.db, userId);
|
||||||
}
|
}
|
||||||
@@ -574,6 +893,10 @@ export class StorageService {
|
|||||||
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
|
||||||
|
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||||
|
|
||||||
async saveTrustedTwoFactorDeviceToken(
|
async saveTrustedTwoFactorDeviceToken(
|
||||||
@@ -590,6 +913,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> {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
requestYubicoApiCredentials,
|
||||||
|
type YubicoApiCredentials,
|
||||||
|
} from '../utils/yubico-otp';
|
||||||
|
|
||||||
|
export const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||||
|
export const YUBICO_SECRET_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||||
|
export const YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY = 'yubico.bootstrap.claim.v1';
|
||||||
|
|
||||||
|
const YUBICO_BOOTSTRAP_CLAIM_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface YubicoCredentialInitializationResult {
|
||||||
|
credentials: YubicoApiCredentials;
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYubicoCredentials(db: D1Database): Promise<YubicoApiCredentials | null> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT key, value FROM config WHERE key IN (?, ?)')
|
||||||
|
.bind(YUBICO_CLIENT_ID_CONFIG_KEY, YUBICO_SECRET_KEY_CONFIG_KEY)
|
||||||
|
.all<{ key: string; value: string }>();
|
||||||
|
const values = new Map((result.results || []).map((row) => [row.key, String(row.value || '').trim()]));
|
||||||
|
const clientId = values.get(YUBICO_CLIENT_ID_CONFIG_KEY) || '';
|
||||||
|
const secretKey = values.get(YUBICO_SECRET_KEY_CONFIG_KEY) || '';
|
||||||
|
return clientId && secretKey ? { clientId, secretKey } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceYubicoCredentials(
|
||||||
|
db: D1Database,
|
||||||
|
credentials: YubicoApiCredentials
|
||||||
|
): Promise<void> {
|
||||||
|
const clientId = String(credentials.clientId || '').trim();
|
||||||
|
const secretKey = String(credentials.secretKey || '').trim();
|
||||||
|
if (!clientId || !secretKey) throw new Error('Yubico credentials are incomplete');
|
||||||
|
await db.batch([
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).bind(YUBICO_CLIENT_ID_CONFIG_KEY, clientId),
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).bind(YUBICO_SECRET_KEY_CONFIG_KEY, secretKey),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireBootstrapClaim(db: D1Database): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM config WHERE key = ? AND CAST(value AS INTEGER) < ?')
|
||||||
|
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, now)
|
||||||
|
.run();
|
||||||
|
const claim = `${now + YUBICO_BOOTSTRAP_CLAIM_TTL_MS}:${crypto.randomUUID()}`;
|
||||||
|
const result = await db
|
||||||
|
.prepare('INSERT OR IGNORE INTO config(key, value) VALUES(?, ?)')
|
||||||
|
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0 ? claim : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releaseBootstrapClaim(db: D1Database, claim: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM config WHERE key = ? AND value = ?')
|
||||||
|
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeYubicoCredentialsOnce(
|
||||||
|
db: D1Database,
|
||||||
|
email: string,
|
||||||
|
otp: string
|
||||||
|
): Promise<YubicoCredentialInitializationResult | null> {
|
||||||
|
const existing = await getYubicoCredentials(db);
|
||||||
|
if (existing) return { credentials: existing, created: false };
|
||||||
|
|
||||||
|
const claim = await acquireBootstrapClaim(db);
|
||||||
|
if (!claim) {
|
||||||
|
const concurrentlyCreated = await getYubicoCredentials(db);
|
||||||
|
return concurrentlyCreated ? { credentials: concurrentlyCreated, created: false } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rechecked = await getYubicoCredentials(db);
|
||||||
|
if (rechecked) return { credentials: rechecked, created: false };
|
||||||
|
|
||||||
|
const issued = await requestYubicoApiCredentials(email, otp);
|
||||||
|
if (!issued?.clientId || !issued.secretKey) return null;
|
||||||
|
|
||||||
|
const configuredDuringRequest = await getYubicoCredentials(db);
|
||||||
|
if (configuredDuringRequest) {
|
||||||
|
return { credentials: configuredDuringRequest, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await replaceYubicoCredentials(db, issued);
|
||||||
|
return { credentials: issued, created: true };
|
||||||
|
} finally {
|
||||||
|
await releaseBootstrapClaim(db, claim).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
|
||||||
|
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
|
||||||
|
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
|
||||||
|
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
|
||||||
|
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
|
||||||
|
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
|
||||||
|
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
|
||||||
|
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
|
||||||
|
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
|
||||||
|
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
|
||||||
|
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
|
||||||
|
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
|
||||||
|
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
|
||||||
|
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
|
||||||
|
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
|
||||||
|
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
|
||||||
|
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
|
||||||
|
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
|
||||||
|
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
|
||||||
|
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
|
||||||
|
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
|
||||||
|
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
|
||||||
|
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
|
||||||
|
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
|
||||||
|
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
|
||||||
|
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
|
||||||
|
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
|
||||||
|
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
|
||||||
|
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
|
||||||
|
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
|
||||||
|
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
|
||||||
|
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
|
||||||
|
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
|
||||||
|
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
|
||||||
|
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
|
||||||
|
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
|
||||||
|
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
|
||||||
|
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
|
||||||
|
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
|
||||||
|
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
|
||||||
|
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
|
||||||
|
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
|
||||||
|
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
|
||||||
|
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
|
||||||
|
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
|
||||||
|
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
|
||||||
|
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
|
||||||
|
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
|
||||||
|
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
|
||||||
|
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
|
||||||
|
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
|
||||||
|
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
|
||||||
|
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
|
||||||
|
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
|
||||||
|
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
|
||||||
|
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
|
||||||
|
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
|
||||||
|
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
|
||||||
|
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
|
||||||
|
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
|
||||||
|
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
|
||||||
|
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
|
||||||
|
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
|
||||||
|
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
|
||||||
|
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
|
||||||
|
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
|
||||||
|
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
|
||||||
|
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
|
||||||
|
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
|
||||||
|
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
|
||||||
|
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
|
||||||
|
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
|
||||||
|
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
|
||||||
|
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
|
||||||
|
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
|
||||||
|
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
|
||||||
|
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
|
||||||
|
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
|
||||||
|
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
|
||||||
|
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
|
||||||
|
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
|
||||||
|
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
|
||||||
|
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
|
||||||
|
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
|
||||||
|
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
|
||||||
|
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
|
||||||
|
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
|
||||||
|
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
|
||||||
|
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
|
||||||
|
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
|
||||||
|
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"source": "https://github.com/bitwarden/server",
|
||||||
|
"ref": "main",
|
||||||
|
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||||
|
"rulesCount": 91,
|
||||||
|
"domainsCount": 436,
|
||||||
|
"sourceFiles": [
|
||||||
|
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"src/Core/Utilities/StaticStore.cs"
|
||||||
|
],
|
||||||
|
"sourceUrls": [
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user