101 Commits

Author SHA1 Message Date
shuaiplus d5c2ab2b0f refactor: remove unused TOTP styling for cleaner code 2026-06-16 19:26:21 +08:00
shuaiplus 9e0908f43c feat: enhance TOTP formatting and improve responsive styles for TOTP codes display 2026-06-16 19:17:05 +08:00
shuaiplus 7b3be2c819 feat: add duplicate detection modes and UI enhancements for managing duplicates 2026-06-15 20:48:57 +08:00
shuaiplus a8183166ac fix: add S3 addressing style option
Add a configurable S3 addressing style for remote backups while keeping path-style as the default for existing configurations. Use virtual-hosted-style to support providers such as Tencent COS buckets that reject path-style requests.
2026-06-15 16:53:28 +08:00
shuaiplus f6169b7610 fix: add support for trusted two-factor device tokens in backup import and export 2026-06-13 17:45:01 +08:00
shuaiplus 493f901ec1 fix: refine typography styles for improved readability and consistency 2026-06-13 17:20:25 +08:00
shuaiplus b4dfb0409b fix: improve network status handling and probe logic 2026-06-13 17:05:30 +08:00
shuaiplus a06cb0ed71 fix: serialize Bitwarden CSV login URIs 2026-06-13 16:38:25 +08:00
DiaMeoww b0242265f4 fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
2026-06-13 16:38:25 +08:00
Wankko Ree b444c0f4b8 fix: use decrypted password histories when duplicate handling 2026-06-12 17:21:23 +08:00
shuaiplus b1b25fe678 feat: update version to 1.6.1 in package.json and app-version.ts 2026-06-12 16:47:45 +08:00
shuaiplus 7cf2ab7c88 feat: add formatDateTime function for improved date handling in SettingsPage 2026-06-12 16:41:58 +08:00
shuaiplus 1918735520 feat: refine two-factor authentication response handling to align with Bitwarden Identity 2026-06-12 16:31:43 +08:00
shuaiplus c652cc1533 feat: implement device login approval system
Add a complete device authentication approval flow that allows users to approve login requests from new devices on their already-authenticated devices.

Core features:
- Create authentication requests when logging in from new devices
- Display pending requests with device info, IP address, and fingerprint phrases
- Approve or deny requests from web interface with real-time notifications
- Support multiple auth request types (authenticate & unlock, unlock only)
- Automatic expiration and cleanup of stale requests

Backend changes:
- Add auth_requests table with proper indexes for efficient queries
- Implement full CRUD API for authentication requests
- Add notification hub integration for real-time updates
- Add device fingerprint phrase generation for security verification

Frontend changes:
- Add AuthRequestApprovalDialog component for approving/denying requests
- Add PendingAuthRequestsPanel component to display and manage pending requests
- Integrate panels into Security and Settings pages
- Add fingerprint wordlist for generating human-readable verification phrases
- Update i18n translations for all supported languages

Security considerations:
- Access code verification to prevent unauthorized access
- Device fingerprint validation for additional security layer
- IP address and country tracking for audit purposes
- Automatic expiration of old requests (15 minutes)
- Only most recent request per device can be approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:12:11 +08:00
shuaiplus e9aef72df7 feat: add loading skeleton components and styles for improved UI experience 2026-06-11 21:00:16 +08:00
shuaiplus 9adb24d4bb feat: implement two-factor authentication endpoints and related functionality 2026-06-11 16:53:51 +08:00
shuaiplus 563570e3e0 feat: add compatibility validation for cipher fields during import and storage 2026-06-11 15:02:55 +08:00
shuaiplus 3035a77579 chore: update version to 1.6.0 in package.json and app-version.ts 2026-06-10 17:05:32 +08:00
shuaiplus 28333f0e9b feat: update README to enhance PWA and Passkey features descriptions 2026-06-10 16:51:07 +08:00
shuaiplus 91320a4eba fix: persist offline unlock record during passkey PRF login
- Add fallbackKdfIterations parameter to completeLoginWithVaultKeys
- Save offline unlock record (email, profile, profileKey, kdfIterations)
  when completing vault-key-based login, ensuring offline unlock works
  after passkey (PRF) authentication
- Pass through fallbackIterations from performPasskeyLogin caller
- Add .reasonix/ to .gitignore
2026-06-10 13:44:43 +08:00
shuaiplus 19b96a7aca feat: add passkey unlock functionality and improve related error handling 2026-06-10 12:10:11 +08:00
shuaiplus 18e0396c0a feat: enhance account passkey functionality and improve error handling 2026-06-10 12:09:25 +08:00
shuaiplus 18d3490c4f feat: implement account passkey functionality
- Added functions for managing account passkeys including creation, listing, updating, and deletion.
- Introduced login methods using account passkeys with options for direct unlock and login-only modes.
- Enhanced error handling and response parsing for passkey-related API calls.
- Updated UI styles for account passkey management components.
- Added new translations for account passkey features in multiple languages.
- Modified network status handling to improve service reachability checks.
2026-06-10 00:53:41 +08:00
shuaiplus 615caf5946 feat: improve offline PWA resilience 2026-06-09 14:09:46 +08:00
rootphantomer 1a10df4a18 fix: preserve cipher edit time during auto repair 2026-06-09 12:14:11 +08:00
shuaiplus d4749d3f82 feat: add PWA offline unlock support 2026-06-09 12:09:44 +08:00
shuaiplus 5ed7c949c1 feat: add remote backup restore and attachment download functionality 2026-06-07 21:06:34 +08:00
shuaiplus af70cab766 feat: implement BackupTransferRunner for managing backup processes and enhance backup handling 2026-06-07 20:43:43 +08:00
shuaiplus bfea5d0a1c fix: add support for KeePass CSV import format and enhance import parsing logic 2026-06-07 19:18:17 +08:00
shuaiplus cda654e1c3 fix: enhance cipher login URI handling and import format support 2026-06-06 22:43:16 +08:00
shuaiplus 1ee7b0f31b Fix initial i18n render crash on auth pages
Initialize locale messages before the first app render so the auth page does not diff from the fallback language into the detected locale during startup.

Mark the app root as non-translatable and keep the document language synchronized with the active locale to reduce browser translation DOM mutations.
2026-06-06 19:30:35 +08:00
shuaiplus 2d2cbea530 fix: add .tmp-bitwarden-clients/ to .gitignore 2026-05-31 21:23:53 +08:00
shuaiplus 4f5d992f10 fix: enhance cipher handling with repairable URI support and sync improvements 2026-05-31 19:53:42 +08:00
52assert 667afa305b fix(deploy): make KV deploy idempotent
Adapted from #233 with deploy build kept in wrangler config.
2026-05-31 01:20:14 +08:00
shuaiplus 85bd2fa4bf fix: streamline deployment commands in configuration files 2026-05-31 01:15:00 +08:00
shuaiplus fd9707c396 fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling 2026-05-31 01:03:32 +08:00
shuaiplus 192071e4a7 fix: enhance cipher key handling and compatibility for secure notes 2026-05-30 02:43:09 +08:00
shuaiplus fcf7c80daa fix: adjust input padding for improved layout in forms and responsive styles 2026-05-30 02:34:45 +08:00
shuaiplus ed9251c014 fix: enhance compatibility for cipher login normalization and uri handling 2026-05-30 02:26:36 +08:00
shuaiplus a75955ca6d fix: update password verification to support legacy client hashes 2026-05-23 23:07:10 +08:00
shuaiplus 03f7fbf601 fix: repair mixed cipher key encryption handling 2026-05-23 12:43:44 +08:00
shuaiplus a63336764f fix: improve lock timeout retrieval by handling null and empty values 2026-05-23 03:19:49 +08:00
shuaiplus f56d7f01ca fix: add content length validation and timeout handling for icon fetching 2026-05-23 03:17:24 +08:00
shuaiplus 8ff60aed24 fix: remove unused change password handling functions from public route 2026-05-23 03:08:21 +08:00
shuaiplus 749de4e2e1 fix: update server hash prefix handling for password hashing and verification 2026-05-23 03:00:58 +08:00
shuaiplus ea9e238aa7 fix: remove checks for portable admins in backup settings saving and normalization 2026-05-23 02:53:03 +08:00
shuaiplus 22d267f5bc fix: remove unused saveRefreshTokenRecord parameter from getRefreshTokenRecord 2026-05-23 02:42:08 +08:00
shuaiplus 18eefd1174 fix: simplify login identifier construction in two-factor recovery and token handling 2026-05-23 02:22:04 +08:00
shuaiplus d468745841 fix: restore ip-scoped password login lockout 2026-05-23 02:12:40 +08:00
shuaiplus 970621c459 fix: remove optional TOTP_SECRET from environment bindings 2026-05-23 02:07:59 +08:00
shuaiplus 385a873e65 fix: improve device validation logic in refresh token handling 2026-05-23 02:00:41 +08:00
shuaiplus 56185ecb69 fix: strip plaintext login helpers from cipher payload 2026-05-23 01:49:34 +08:00
shuaiplus 04ebfc7021 feat: refactor cipher login data type for improved clarity 2026-05-18 02:13:01 +08:00
shuaiplus c50247b8fe feat: add URI checksum repair functionality for ciphers 2026-05-18 01:59:02 +08:00
shuaiplus 776408e9d0 feat: enhance SSH key handling with Ed25519 support and PEM formatting 2026-05-16 16:34:06 +08:00
shuaiplus e641da517d feat: add uriChecksum handling and sha256Base64 function for enhanced security 2026-05-16 16:22:43 +08:00
shuaiplus b7878ffe01 feat: improve scrollbar styles and dark mode compatibility 2026-05-15 19:12:40 +08:00
shuaiplus bbad9d60a7 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-05-15 18:28:09 +08:00
shuaiplus ed58467766 feat: enhance authorized devices table layout and styling 2026-05-15 18:28:05 +08:00
agesky 2f911e66a6 Update README.md
修改一处描述错误
2026-05-15 11:12:47 +08:00
shuaiplus d06e050162 feat: Updated visual rapid deployment instructions, added JWT_SECRET settings and Workers custom domain prompts 2026-05-14 22:54:54 +08:00
shuaiplus d0dc31ce86 feat: enhance attachment metadata handling and add change password URI support 2026-05-14 22:46:29 +08:00
shuaiplus f64abaa75d feat: enhance search functionality by including cipher ID in search text 2026-05-14 10:52:11 +08:00
shuaiplus 7312086f92 feat: add restore functionality for deleted items with corresponding UI updates 2026-05-14 10:40:32 +08:00
shuaiplus 3e4c104e1d feat: added logging system 2026-05-14 02:42:15 +08:00
shuaiplus 17ceec45b1 feat: implement user and device cache invalidation in AuthService 2026-05-12 19:12:53 +08:00
shuaiplus 2685741386 feat: add permanent trust functionality for devices with corresponding API and UI updates 2026-05-12 18:01:04 +08:00
shuaiplus 83a1fc2376 feat: enhance TOTP settings UI with improved layout and status indication 2026-05-12 15:55:05 +08:00
shuaiplus 06431c4145 feat: enhance mobile responsiveness for management routes and table layout 2026-05-12 15:16:17 +08:00
shuaiplus 700910099b feat: adjust eye button positioning and hover effect for password toggle 2026-05-12 00:22:48 +08:00
shuaiplus 6b671450a8 feat: update version to 1.5.2 in package.json, package-lock.json, and app-version.ts 2026-05-12 00:11:08 +08:00
shuaiplus c0df6d1c16 feat: update styling for sensitive actions module to enhance UI consistency 2026-05-11 23:49:47 +08:00
shuaiplus 35f9512d94 feat: enhance admin invites UI and improve styling for better usability 2026-05-11 20:22:47 +08:00
shuaiplus 9e39161fc7 Add new payment logo SVGs for Discover, JCB, Maestro, Mastercard, UnionPay, and Visa
- Added discover.svg for Discover card logo.
- Added jcb.svg for JCB card logo.
- Added maestro.svg for Maestro card logo.
- Added mastercard.svg for Mastercard logo.
- Added unionpay.svg for UnionPay logo.
- Added visa.svg for Visa card logo.
2026-05-10 23:33:41 +08:00
shuaiplus 7c58282e42 feat: add registration invite code handling and improve error translations
- Updated AuthViews component to conditionally show invite code field based on registrationInviteRequired prop.
- Enhanced error handling in auth API functions to use translateServerError for better user feedback.
- Added new translations for various server error messages in English, Spanish, Russian, Chinese (Simplified and Traditional).
- Modified demo initial bootstrap state to include registrationInviteRequired flag.
- Updated types to include registrationInviteRequired in WebBootstrapResponse.
2026-05-10 23:07:07 +08:00
shuaiplus e0d81f2733 feat: reset password visibility and history state on cipher selection change 2026-05-10 19:57:32 +08:00
shuaiplus 1d23b3fe5e feat: add wiki directory to .gitignore for better file management 2026-05-10 19:02:56 +08:00
shuaiplus a0d4d7a1ff feat: update custom field input to textarea for better usability; enhance styles for improved display 2026-05-10 18:17:09 +08:00
shuaiplus 2f1b61e883 feat: update backup recommendations UI; enhance styles and structure for better user experience 2026-05-10 01:54:12 +08:00
shuaiplus 4e62c90700 feat: enhance website icon loading logic; implement error handling and timeout management 2026-05-09 23:46:33 +08:00
shuaiplus 7afb496eb0 feat: enhance website icon loading mechanism; implement icon loading state management and error handling 2026-05-09 23:00:56 +08:00
shuaiplus 5809e3eebc feat: remove drag-and-drop functionality for TOTP and website rows; update styles and translations for improved user experience 2026-05-08 16:09:02 +08:00
shuaiplus 2e9bbe6801 feat: add resourcePriorityPlugin for enhanced resource loading; update chunking strategy in Vite config 2026-05-08 01:20:00 +08:00
shuaiplus dc0eec7c54 feat: preload DomainRulesPage component for authenticated workspace and demo experience 2026-05-07 23:26:48 +08:00
shuaiplus a0605299f0 feat: implement navigation layout options and styles in AppAuthenticatedShell component; add translations for navigation layout in multiple languages 2026-05-07 23:20:30 +08:00
shuaiplus db68437a0b feat: add admin pagination styling to AdminPage component 2026-05-07 23:00:40 +08:00
shuaiplus 77d8411ea9 feat: add search index headers and robots.txt generation for SEO control 2026-05-07 22:31:15 +08:00
shuaiplus 0c1ab3db48 feat: update development and deployment scripts to include build step; refactor navigation links in AppAuthenticatedShell component 2026-05-07 22:23:39 +08:00
shuaiplus 6cc6e94b91 feat: update README and README_EN to improve layout and accessibility of links 2026-05-07 20:56:47 +08:00
shuaiplus 37ae493fa7 feat: add contributing guidelines and pull request template; update schema comments and documentation 2026-05-07 20:29:39 +08:00
shuaiplus 33f7c5d88a feat: update schema synchronization note and add device fields to refresh_tokens and devices tables 2026-05-07 19:49:38 +08:00
shuaiplus c6c8979772 feat: include domain settings count validation in backup import functions 2026-05-07 19:43:06 +08:00
shuaiplus a00279f47d feat: add domain settings support in backup import and export processes 2026-05-07 19:36:32 +08:00
shuaiplus 669d7ef242 feat: add function to export portable backup settings envelope 2026-05-07 19:23:22 +08:00
shuaiplus 97d2117e15 feat: enhance TOTP configuration parsing with algorithm, digits, and period options 2026-05-06 22:23:26 +08:00
shuaiplus 429b747710 feat: add mobile detail sheet styles and improve text overflow handling in vault 2026-05-06 22:11:14 +08:00
shuaiplus a06853835d feat: improve JSON formatting for global domains and custom domains 2026-05-06 01:20:20 +08:00
shuaiplus c4ff063865 feat: format rules JSON output for better readability 2026-05-06 01:11:57 +08:00
shuaiplus 70b0a3a394 feat: add NodeWarden-compat to .gitignore 2026-05-06 00:50:45 +08:00
shuaiplus e7c07fda4e feat: enhance navigation with collapsible groups and improve styles 2026-05-06 00:47:18 +08:00
shuaiplus 0a001bebcc feat: add domain rules management feature
- Introduced a new DomainRulesPage component for managing custom and global equivalent domains.
- Updated AppMainRoutes to include a route for domain rules.
- Added API functions to fetch and save domain rules.
- Enhanced localization with new strings for domain rules in multiple languages.
- Updated styles for the new domain rules interface and ensured responsiveness.
- Added types for domain rules in the TypeScript definitions.
2026-05-06 00:33:09 +08:00
159 changed files with 27555 additions and 1812 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx vite *)",
"Bash(npx tsc *)"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
*.pid
+31
View File
@@ -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? -->
+2 -2
View File
@@ -67,7 +67,7 @@ class SecurityReport {
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
@@ -119,7 +119,7 @@ class SecurityReport {
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.*',
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
+51
View File
@@ -0,0 +1,51 @@
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@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
- name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request
uses: peter-evans/create-pull-request@v6
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
+18 -1
View File
@@ -26,7 +26,7 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
.vite-tailwind.err
# Environment
.env
.env.local
@@ -40,7 +40,24 @@ npm-debug.log*
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/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
.mcp.json
opencode.jsonc
.cursor/
+133
View File
@@ -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
```
+44 -21
View File
@@ -14,16 +14,14 @@
</p>
<p align="center">
<a href="./RELEASE_NOTES.md">更新日志</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
@@ -36,16 +34,19 @@ English: <a href="./README_EN.md"><code>README_EN.md</code></a>
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
@@ -60,16 +61,21 @@ English: <a href="./README_EN.md"><code>README_EN.md</code></a>
---
## 网页部署
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 `Continue with GitHub`
4. 选择你刚刚 Fork 的仓库
5. 保持默认配置继续部署
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
7. 等部署完成后,打开生成的 Workers 域名
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
> [!TIP]
> 默认R2与可选KV的区别:
@@ -107,10 +113,27 @@ npm run dev:kv
---
## 云端备份说明
## 主要特性
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
### PWA 渐进式 Web 应用
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
+35 -16
View File
@@ -15,15 +15,14 @@
</p>
<p align="center">
<a href="./RELEASE_NOTES.md">Release Notes</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
中文说明:<a href="./README.md"><code>README.md</code></a>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
@@ -38,16 +37,19 @@
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
@@ -100,17 +102,34 @@ npm run dev:kv
---
## Cloud Backup Notes
## Key Features
- Remote backup supports **WebDAV** and **E3**
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
+785
View File
@@ -0,0 +1,785 @@
# NodeWarden Passkey 登录研究记录
记录日期:2026-06-09
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
## 结论先放前面
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
下面按代码链路展开。
## 术语边界
这里有三个容易混淆的东西,文档后面严格区分:
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
## NodeWarden 现状
### 路由和入口
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token``/identity/accounts/prelogin``/api/accounts/register`
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
- `src/handlers/identity.ts`OAuth/token 兼容入口。
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
目前公开路由没有:
- `GET /identity/accounts/webauthn/assertion-options`
- `POST /identity/connect/token``grant_type=webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `GET/POST/PUT /api/webauthn`
### 注册链路
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts``registerAccount()`
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
- 随机生成 64 字节 vault symmetric key。
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
- POST `/api/accounts/register`,提交 `email``name``masterPasswordHash``key`、KDF 参数、invite code、`keys.publicKey``keys.encryptedPrivateKey`
后端 `src/handlers/accounts.ts``handleRegister()`
- 第一个用户自动成为 admin,后续用户需要 invite。
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`
- 保存 `users.key``users.private_key``users.public_key`、KDF 参数、`security_stamp`
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
### 主密码登录链路
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts``performPasswordLogin()`
-`deriveLoginHashLocally()` 得到 masterKey 和 client hash。
-`loginWithPassword()` POST `/identity/connect/token`
- token 成功后 `completeLogin()``token.Key` 和本地 masterKey 解开 vault key。
- 保存离线解锁记录。
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()``getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
后端 `src/handlers/identity.ts``handleToken()` 当前支持:
- `grant_type=password`
- `grant_type=client_credentials`
- `grant_type=refresh_token`
密码登录成功后会:
- 验证 IP 登录频率和用户状态。
- `AuthService.verifyPassword()` 验证 client hash。
- 处理 TOTP 或 remember 2FA token。
- 记录/更新 device。
- 生成 access token 和 refresh token。
- 返回 `Key``PrivateKey``AccountKeys`、KDF 参数、`UserDecryptionOptions`
### UserDecryptionOptions 和 sync
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
- `HasMasterPassword: true`
- `MasterPasswordUnlock`
- `TrustedDeviceOption: null`
- `KeyConnectorOption: null`
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
passkey 登录必须新增两类 shape
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
### 现有 passkey 相关代码
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
- `src/types/index.ts``CipherLogin.fido2Credentials`
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
- `webapp/src/lib/types.ts``CipherLoginPasskey`
这部分是“保存网站 passkey”,不是账户登录。
`src/utils/passkey.ts` 只有:
- `bytesToBase64Url()`
- `base64UrlToBytes()`
- `randomChallenge()`
- `parseClientDataJSON()`
缺少的核心能力:
- attestation verification
- assertion verification
- authenticator public key 格式处理
- signature verification
- sign counter 更新
- userHandle 与 user id 绑定验证
- origin/RP ID 验证
- challenge 过期和防重放
### 数据库和备份影响
NodeWarden schema 在这些地方需要同步:
- `migrations/0001_init.sql`
- `src/services/storage-schema.ts`
- `wrangler.toml` migrations
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `shared/backup-schema` 相关类型
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
## 官方 Bitwarden server 参考
上游代码位置:
- `.codex-upstream/bitwarden-server`
- 研究时 HEAD`574f3fd`
官方 server 里也有两个 WebAuthn 概念:
- 传统 WebAuthn 2FA`TwoFactorController``WebAuthnTokenProvider`
- 账户 passkey 登录:`WebAuthnLogin`
本项目要参考的是后者。
### 公开 passkey 登录入口
`src/Identity/Controllers/AccountsController.cs`
- `GET /accounts/webauthn/assertion-options`
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
- response 包含:
- `options`
- `token`
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
- scope 为 `Authentication`
- token 生命周期约 17 分钟
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- 新增 OAuth extension grant`grant_type=webauthn`
- 从 form 读取:
- `token`
- `deviceResponse`
- 解开 token,校验 scope 必须是 `Authentication`
- 反序列化 `AuthenticatorAssertionRawResponse`
- 调用 `AssertWebAuthnLoginCredential`
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
`src/Identity/IdentityServer/ApiClient.cs`
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
### 账户 passkey 管理接口
`src/Api/Auth/Controllers/WebAuthnController.cs`
官方 authenticated API
- `GET /webauthn`:列出账户 passkey credentials。
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
- `POST /webauthn`:保存新 credential。
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
- `POST /webauthn/{id}/delete`:删除 credential。
官方创建 credential 时保存:
- `name`
- `token`
- `deviceResponse`
- `supportsPrf`
- 可选 `encryptedUserKey`
- 可选 `encryptedPublicKey`
- 可选 `encryptedPrivateKey`
官方最多允许 5 个账户 passkey credentials。
### 官方 WebAuthnCredential 表
`src/Core/Auth/Entities/WebAuthnCredential.cs`
字段:
- `Id`
- `UserId`
- `Name`
- `PublicKey`
- `CredentialId`
- `Counter`
- `Type`
- `AaGuid`
- `EncryptedUserKey`
- `EncryptedPrivateKey`
- `EncryptedPublicKey`
- `SupportsPrf`
- `CreationDate`
- `RevisionDate`
SQLite migration`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
`GetPrfStatus()`
- `Unsupported``SupportsPrf` 为 false。
- `Supported`credential 支持 PRF,但还没有完整 encrypted keyset。
- `Enabled``EncryptedUserKey``EncryptedPrivateKey``EncryptedPublicKey` 都存在。
### 官方创建和认证策略
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- 使用 Fido2NetLib。
- `user.id` 是用户 id bytes。
- `user.name/displayName` 使用用户邮箱。
- 排除当前用户已有 credential ids。
- `residentKey: required`
- `userVerification: required`
- `attestation: none`
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `allowCredentials` 传空数组。
- `userVerification: required`
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
`CreateWebAuthnLoginCredentialCommand.cs`
- 限制每用户最多 5 个。
- 检查 credential id 在该用户下不能重复。
- FIDO `MakeNewCredentialAsync` 验证 attestation。
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
`AssertWebAuthnLoginCredentialCommand.cs`
- 先用 challenge cache 防重放。
- 从 assertion response 的 `userHandle` 解析出 user id。
- 加载该用户所有 WebAuthn credentials。
- 用 credential id 找到记录。
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
- 成功后更新 counter。
### 官方 PRF 解密协议
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
`WebAuthnPrfDecryptionOption` 字段:
- `EncryptedPrivateKey`
- `EncryptedUserKey`
- `CredentialId`
- `Transports`
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`
- 如果 credential 没有 PRF keysetpasskey 只能认证账号,不能解开 vault。
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`
## 官方 Bitwarden web/browser client 参考
上游代码位置:
- `.codex-upstream/bitwarden-clients`
- `.codex-upstream/bitwarden-browser`
- 两者研究时 HEAD 都是 `825f9be`browser repo 内容和 clients monorepo 对应。
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
### 登录按钮可见性
`libs/auth/src/angular/login/default-login-component.service.ts`
- 默认只对 `ClientType.Web` 开启 passkey 登录。
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- browser extension 覆盖逻辑:只对 Chromium 开启。
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
### Passkey 登录页
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
流程:
1. 进入 `/login-with-passkey` 后自动开始认证。
2.`webAuthnLoginService.getCredentialAssertionOptions()`
3.`webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`
4.`webAuthnLoginService.logIn(assertion)` 走 identity token grant。
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
7. 成功路由:
- Web`/vault`
- Browser`/tabs/vault`
- Desktop`/vault`
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
### 客户端 passkey 登录请求
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- GET `${identityUrl}/accounts/webauthn/assertion-options`
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `navigator.credentials.get({ publicKey: options })`
- 会主动加 PRF extension
- salt 是 `SHA-256("passwordless-login")`
- extension shape 是 `extensions.prf.eval.first`
-`credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
-`WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
- 构造 `WebAuthnLoginAssertionResponseRequest`
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- salt 常量:`passwordless-login`
- 先 SHA-256。
- 再用 HKDF expand 拆成 64 字节:
- `"enc"` 32 bytes
- `"mac"` 32 bytes
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
form encoded token 请求字段:
- `grant_type=webauthn`
- `token=<server assertion options token>`
- `deviceResponse=<JSON string>`
- 还会带 common device request 字段。
`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
`deviceResponse` shape
- `id`
- `rawId`
- `type`
- `extensions: {}`
- `response.authenticatorData`
- `response.signature`
- `response.clientDataJSON`
- `response.userHandle`
全部二进制字段使用 base64url。
### 客户端如何用 PRF 解 vault key
`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。
- `setUserKey()`
- 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。
- 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`
1. 用 PRF key unwrap `encryptedPrivateKey`
2. 用 private key decapsulate `encryptedUserKey`
3. 得到 user key,写入 `keyService`
核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。
### 官方 web 设置页注册 passkey
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
调用的 API
- `POST /webauthn/attestation-options`
- `POST /webauthn/assertion-options`
- `POST /webauthn`
- `GET /webauthn`
- `POST /webauthn/{id}/delete`
- `PUT /webauthn`
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
创建流程:
1. 用户做 secret verification。
2. 请求 attestation options。
3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`
4. 从 client extension results 判断 `supportsPrf`
5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`
- `allowCredentials` 锁定刚创建的 credential。
- 使用同一个 challenge、rpId、timeout、userVerification。
- 带 PRF eval salt。
6. 用 PRF key 和当前 user key 创建 rotateable keyset。
7. 保存 credential,带上 `encryptedUserKey``encryptedPublicKey``encryptedPrivateKey`
删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。
`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `Enabled = 0`
- `Supported = 1`
- `Unsupported = 2`
## NodeWarden 应实现的协议形状
### 公开登录流程
目标兼容官方客户端和 NodeWarden 自己 web
1. `GET /identity/accounts/webauthn/assertion-options`
- 生成 discoverable credential assertion options。
- `allowCredentials: []`
- `userVerification: "required"`
- 返回 `{ options, token }`
- token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。
2. Browser/web 调 `navigator.credentials.get()`
- NodeWarden 自己 web 也要使用 PRF extension。
- PRF salt 必须和官方一致:`SHA-256("passwordless-login")`
3. `POST /identity/connect/token`
- 支持 `grant_type=webauthn`
- 接收 `token``deviceResponse`、device fields。
- 解 token,校验 challenge/scope/过期。
- 验证 assertion。
-`userHandle` 找到 user id。
- 从 credential id 找到 passkey record。
- 更新 counter。
- 记录/更新 device。
- 返回 access/refresh token、`AccountKeys``UserDecryptionOptions.WebAuthnPrfOption`
如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwardenpasskey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
### 账户 passkey 管理流程
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`
- `GET /api/webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `POST /api/webauthn`
- `PUT /api/webauthn`
- `POST /api/webauthn/:id/delete`
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases
- `/webauthn`
- `/webauthn/attestation-options`
- `/webauthn/assertion-options`
- `/webauthn/:id/delete`
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`
### 建议新增表
按 NodeWarden 命名风格,建议用小写 snake_case
```sql
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
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_user_credential
ON webauthn_credentials(user_id, credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
```
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
PRF status 不必落库为枚举,可以由字段计算:
- `supports_prf = 0` => `Unsupported`
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
### Challenge/token 存储
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
- tokenHMAC/JWT 样式,绑定 `scope``challenge``userId?``rpId``createdAt``expiresAt`
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash``scope``user_id``expires_at``used_at`
- 登录 assertion options 是公开接口,不绑定 user idcreate/update/delete 管理流程应绑定 user id。
- 验证成功后立即 mark used。
建议 scopes
- `Authentication`
- `CreateCredential`
- `UpdateKeySet`
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
### 服务端 WebAuthn 验证库
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions``verifyRegistrationResponse``generateAuthenticationOptions``verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
## NodeWarden web 需要改的地方
### 登录页
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx``webapp/src/lib/app-auth.ts` 管。
新增:
- 登录页增加“使用 passkey 登录”按钮。
- 新增 `performPasskeyLogin()`
1. GET `/identity/accounts/webauthn/assertion-options`
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
3. `navigator.credentials.get()`,带 PRF salt。
4. POST `/identity/connect/token``grant_type=webauthn`
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
6. 用本地 PRF key 解出 user key。
7. 构造 `SessionState` 并进入 app。
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
### 设置页
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
新增:
- Passkey 列表。
- 新建 passkey dialog。
- 删除 passkey。
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
自己 web 的新建流程要和官方一致:
1. 已登录状态下先验证主密码或现有 session secret。
2. 请求 attestation options。
3. `navigator.credentials.create()``extensions.prf = {}`
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
### 客户端加密能力
NodeWarden web 当前已经有:
- PBKDF2
- HKDF expand
- Bitwarden EncString 加解密
- RSA-OAEP private key 加密
但 passkey PRF keyset 需要和官方策略对齐:
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
- `encryptedUserKey` 用对应 public key encapsulate user key。
- `encryptedPublicKey` 用于 key rotation。
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
## 扩展兼容要求
### 官方 browser extension
官方 extension passkey 登录入口在:
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- 只在 Chromium 开启。
如果要官方/派生扩展能对 NodeWarden passkey 登录:
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`
- token URL 必须支持 `grant_type=webauthn`
- API URL 必须能访问 `/webauthn` 管理接口。
- response 大小写和字段名要同时照顾 PascalCase/camelCaseNodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
### RP ID 和 origin
自己的 web
- RP ID 通常是站点 host,例如 `vault.example.com`
- origin 是 `https://vault.example.com`
官方 browser extension
- 扩展页面 origin 是 `chrome-extension://...`
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
建议配置化:
- `WEBAUTHN_RP_ID`
- `WEBAUTHN_RP_NAME`
- `WEBAUTHN_ALLOWED_ORIGINS`
默认可以从 request URL 推导 web origin,但生产建议显式配置。
## 安全约束
- 所有账户 passkey 必须 `userVerification: required`
- 登录 assertion 使用 discoverable credential`userHandle` 必须能解析成 user id 并和 credential 记录一致。
- challenge 必须有过期时间和一次性使用标记。
- PRF 输出绝不能传给 server,也不能写入日志。
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
- 每用户 credential 数量限制建议沿用官方 5 个。
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
- 审计日志建议新增:
- `auth.passkey.login.success`
- `auth.passkey.login.failed`
- `account.passkey.create`
- `account.passkey.delete`
- `account.passkey.encryption.enable`
- `account.passkey.rotate`
## 建议实施顺序
### 第一阶段:后端基础
1. 新增 `webauthn_credentials` 和 challenge 表。
2. 新增 storage repo。
3. 接入 WebAuthn 服务端验证库。
4. 实现 assertion options 和 `grant_type=webauthn`
5. token response 加 `WebAuthnPrfOption` shape。
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
### 第二阶段:账户 passkey 管理 API
1. 实现 `/api/webauthn``/webauthn` aliases。
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
3. 加 audit event。
4. 接入 backup export/import。
5. sync response 加 `WebAuthnPrfOptions`
### 第三阶段:NodeWarden 自己 web
1. 登录页 passkey 按钮和 `performPasskeyLogin()`
2. Passkey 设置页。
3. PRF keyset 创建、保存、删除、启用 encryption。
4. 浏览器能力判断和错误提示。
### 第四阶段:扩展兼容
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
2. 校对 `/config` 里 identity/api/web vault URL。
3. 校对 RP ID、allowed origins。
4. 必要时加兼容字段或 alias route。
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
## 待实现清单
- [ ] 设计并落库 `webauthn_credentials`
- [ ] 设计并落库 WebAuthn challenge/replay cache。
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
- [ ] `GET /identity/accounts/webauthn/assertion-options`
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`
- [ ] `UserDecryption.WebAuthnPrfOptions`
- [ ] `/api/webauthn` 管理接口。
- [ ] `/webauthn` 官方客户端 alias。
- [ ] NodeWarden web passkey 登录入口。
- [ ] NodeWarden web passkey 管理页。
- [ ] key rotation 时同步 rotate PRF keysets。
- [ ] backup export/import 覆盖新表。
- [ ] audit logs 覆盖 passkey 管理和登录。
## 关键文件索引
NodeWarden
- `src/router-public.ts`
- `src/router-authenticated.ts`
- `src/handlers/accounts.ts`
- `src/handlers/identity.ts`
- `src/handlers/sync.ts`
- `src/services/auth.ts`
- `src/services/storage-schema.ts`
- `src/services/storage-user-repo.ts`
- `src/services/storage-device-repo.ts`
- `src/utils/passkey.ts`
- `src/utils/user-decryption.ts`
- `src/types/index.ts`
- `webapp/src/lib/api/auth.ts`
- `webapp/src/lib/app-auth.ts`
- `webapp/src/components/AuthViews.tsx`
- `webapp/src/components/SettingsPage.tsx`
Bitwarden server
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
Bitwarden clients/browser
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
+89 -1
View File
@@ -1,8 +1,14 @@
PRAGMA foreign_keys = ON;
-- IMPORTANT:
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
-- This is the initial D1 schema. Keep it in sync with
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
-- Any new table/column/index must be added to both places together.
--
-- WHEN CHANGING THIS:
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
-- - If the new table stores persistent data, update backup export/import.
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
@@ -33,6 +39,15 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS domain_settings (
user_id TEXT PRIMARY KEY,
equivalent_domains TEXT NOT NULL DEFAULT '[]',
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Per-user sync revision date
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
@@ -115,6 +130,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
device_identifier TEXT,
device_session_stamp TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
@@ -137,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
action TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
target_type TEXT,
target_id TEXT,
metadata TEXT,
@@ -145,6 +164,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL,
@@ -155,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
banned INTEGER NOT NULL DEFAULT 0,
banned_at TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
@@ -165,6 +188,33 @@ CREATE TABLE IF NOT EXISTS devices (
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE TABLE IF NOT EXISTS auth_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
organization_id TEXT,
type INTEGER NOT NULL,
request_device_identifier TEXT NOT NULL,
request_device_type INTEGER NOT NULL,
request_ip_address TEXT,
request_country_name TEXT,
response_device_identifier TEXT,
access_code TEXT NOT NULL,
public_key TEXT NOT NULL,
key TEXT,
master_password_hash TEXT,
approved INTEGER,
creation_date TEXT NOT NULL,
response_date TEXT,
authentication_date TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
ON auth_requests(user_id, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
ON auth_requests(user_id, request_device_identifier, creation_date);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -175,6 +225,44 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
ON webauthn_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
ON webauthn_credentials(user_id, updated_at);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
challenge_hash TEXT PRIMARY KEY,
scope TEXT NOT NULL,
user_id TEXT,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
ON webauthn_challenges(user_id, scope);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY,
+430 -202
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.5.1",
"version": "1.6.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -12,10 +12,11 @@
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
@@ -55,10 +56,8 @@
"wrangler": "^4.71.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
+64
View File
@@ -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();
+3
View File
@@ -2,6 +2,9 @@ const fs = require('fs');
const path = require('path');
const vm = require('vm');
// CONTRACT:
// This list is the script-side locale source of truth. Keep it in sync with
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
const localeFiles = [
+16 -1
View File
@@ -1,5 +1,9 @@
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
// CONTRACT:
// This is the authoritative locale consistency gate. It checks key parity,
// placeholder parity, and accidental mostly-English locale files. Run after any
// user-facing text or locale-file change.
const locales = Object.fromEntries(
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
);
@@ -18,6 +22,17 @@ const intentionallyEnglishKeys = new Set([
'txt_dash',
'txt_text_3',
]);
const intentionallyEnglishPrefixes = [
'txt_log_action_',
'txt_log_meta_',
'txt_log_reason_',
'txt_log_target_type_',
'txt_log_trigger_',
];
function isIntentionallyEnglishKey(key) {
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
}
for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort();
@@ -36,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) {
}
if (locale !== 'en') {
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
if (sameAsEnglish.length > 40) {
errors.push({
locale,
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const DEFAULT_REF = 'main';
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
function parseArgs(argv) {
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--ref' && argv[i + 1]) {
args.ref = argv[i + 1];
i += 1;
} else if (arg.startsWith('--ref=')) {
args.ref = arg.slice('--ref='.length);
}
}
return args;
}
function rawUrl(ref, filePath) {
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'NodeWarden global domains sync',
Accept: 'text/plain',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
}
return response.text();
}
function parseEnumTypes(source) {
const map = new Map();
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
if (!enumMatch) {
throw new Error('GlobalEquivalentDomainsType enum was not found');
}
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
let match;
while ((match = entryRe.exec(body)) !== null) {
map.set(match[1], Number(match[2]));
}
if (!map.size) {
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
}
return map;
}
function parseStringList(source) {
const domains = [];
const stringRe = /"((?:\\.|[^"\\])*)"/g;
let match;
while ((match = stringRe.exec(source)) !== null) {
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
}
return Array.from(new Set(domains.filter(Boolean)));
}
function parseGlobalDomains(source, enumTypes) {
const out = [];
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
let match;
while ((match = addRe.exec(source)) !== null) {
const name = match[1];
const type = enumTypes.get(name);
if (!Number.isInteger(type)) {
throw new Error(`GlobalDomains references unknown enum value ${name}`);
}
const domains = parseStringList(match[2]);
if (domains.length < 2) {
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
}
out.push({
type,
domains,
excluded: false,
});
}
if (!out.length) {
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
}
return out;
}
function formatRulesJson(rules) {
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
}
function formatMetaJson(meta) {
return JSON.stringify(meta, null, 2);
}
const { ref } = parseArgs(process.argv.slice(2));
const enumUrl = rawUrl(ref, ENUM_PATH);
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
const [enumSource, staticStoreSource] = await Promise.all([
fetchText(enumUrl),
fetchText(staticStoreUrl),
]);
const enumTypes = parseEnumTypes(enumSource);
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
const rulesJson = formatRulesJson(rules);
async function readJsonFile(filePath) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch {
return null;
}
}
const existingRules = await readJsonFile(OUT_FILE);
const existingMeta = await readJsonFile(META_FILE);
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
const unchangedRef = existingMeta?.ref === ref;
const meta = {
source: 'https://github.com/bitwarden/server',
ref,
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
? existingMeta.generatedAt
: new Date().toISOString(),
rulesCount: rules.length,
domainsCount,
sourceFiles: [
ENUM_PATH,
STATIC_STORE_PATH,
],
sourceUrls: [
enumUrl,
staticStoreUrl,
],
};
await mkdir(OUTPUT_DIR, { recursive: true });
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.5.1';
export const APP_VERSION = '1.6.1';
+11
View File
@@ -1,3 +1,11 @@
// Shared backup settings types used by both Worker and webapp code.
//
// CONTRACT:
// Keep this file serializable and provider-neutral. Runtime state is operational
// metadata; destination fields can contain provider credentials and must be
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
// User-facing provider names should use canonical values here. Legacy aliases
// belong in backend normalization, not in this shared type.
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_S3_REGION = 'auto';
@@ -6,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav';
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
export interface S3BackupDestination {
endpoint: string;
bucket: string;
addressingStyle: S3BackupAddressingStyle;
region: string;
accessKeyId: string;
secretAccessKey: string;
@@ -95,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
return {
endpoint: '',
bucket: '',
addressingStyle: 'path-style',
region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '',
secretAccessKey: '',
+151
View File
@@ -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);
}
+11 -3
View File
@@ -5,10 +5,10 @@
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000,
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
@@ -44,6 +44,9 @@
// Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30,
@@ -145,6 +148,11 @@
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0',
bitwardenServerVersion: '2026.4.1',
// Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
cipherKeyEncryptionFeatureEnabled: true,
},
} as const;
+465
View File
@@ -0,0 +1,465 @@
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 { 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;
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);
await executeConfiguredBackup(
this.env,
storage,
null,
'scheduled',
destination.id,
() => this.touchJob(token)
);
completed += 1;
}
}
return new Response(JSON.stringify({
ok: true,
completed,
}), {
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
);
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 || !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(Boolean)
));
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 (!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',
},
});
}
}
+129 -32
View File
@@ -5,13 +5,17 @@ const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request';
interface WsAttachment {
userId: string;
kind: HubKind;
userId: string | null;
authRequestId: string | null;
handshakeComplete: boolean;
protocol: HubProtocol;
deviceIdentifier: string | null;
@@ -137,11 +141,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
function buildSignalRJsonInvocation(
updateType: number,
payload: Record<string, unknown>,
contextId: string | null
contextId: string | null,
target: string = 'ReceiveMessage'
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
target,
arguments: [
{
ContextId: contextId,
@@ -155,7 +160,8 @@ function buildSignalRJsonInvocation(
function buildSignalRMessagePackInvocation(
updateType: number,
messagePayload: Record<string, unknown>,
contextId: string | null
contextId: string | null,
target: string = 'ReceiveMessage'
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
@@ -163,7 +169,7 @@ function buildSignalRMessagePackInvocation(
1,
{},
null,
'ReceiveMessage',
target,
[
{
ContextId: contextId,
@@ -213,6 +219,20 @@ export class NotificationsHub extends DurableObject<Env> {
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
userId?: string;
authRequestId?: string;
contextId?: string | null;
} | null;
const userId = String(body?.userId || '').trim();
const authRequestId = String(body?.authRequestId || '').trim();
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200,
@@ -222,7 +242,7 @@ export class NotificationsHub extends DurableObject<Env> {
});
}
if (url.pathname !== '/notifications/hub') {
if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
return new Response('Not found', { status: 404 });
}
@@ -232,8 +252,13 @@ export class NotificationsHub extends DurableObject<Env> {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
if (!requestUserId) {
if (!isAnonymousAuthRequestHub && !requestUserId) {
return new Response('Unauthorized', { status: 401 });
}
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
return new Response('Unauthorized', { status: 401 });
}
@@ -248,7 +273,9 @@ export class NotificationsHub extends DurableObject<Env> {
this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({
userId: requestUserId,
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
userId: isAnonymousAuthRequestHub ? null : requestUserId,
authRequestId: requestAuthRequestId,
handshakeComplete: false,
protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier,
@@ -274,7 +301,6 @@ export class NotificationsHub extends DurableObject<Env> {
attachment.handshakeComplete = true;
ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return;
} catch {
// Ignore malformed pre-handshake payloads.
@@ -293,26 +319,22 @@ export class NotificationsHub extends DurableObject<Env> {
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
void ws;
void code;
void reason;
void wasClean;
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
void ws;
void error;
}
private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>();
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier);
}
return Array.from(out);
@@ -349,16 +371,45 @@ export class NotificationsHub extends DurableObject<Env> {
}
}
private broadcastDeviceStatus(userId: string): void {
this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{
private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (
!attachment?.handshakeComplete ||
attachment.kind !== 'anonymous-auth-request' ||
attachment.authRequestId !== authRequestId
) {
continue;
}
const payload = {
UserId: userId,
Date: new Date().toISOString(),
},
null,
null
);
Id: authRequestId,
};
try {
if (attachment.protocol === 'json') {
ws.send(buildSignalRJsonInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
} else {
ws.send(buildSignalRMessagePackInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
}
} catch {
try {
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
}
}
@@ -392,13 +443,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
}
}
export async function notifyAuthRequestResponse(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/auth-request-response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
authRequestId,
contextId: contextId || null,
}),
});
} catch (error) {
console.error('Failed to broadcast auth request response notification:', error);
}
}
export function notifyUserAuthRequest(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(
env,
userId,
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
new Date().toISOString(),
contextId ?? null,
null,
{
UserId: userId,
Id: authRequestId,
}
));
}
async function notifyUserUpdate(
env: Env,
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null,
targetDeviceIdentifier: string | null
targetDeviceIdentifier: string | null,
payloadOverride?: Record<string, unknown> | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
@@ -414,7 +511,7 @@ async function notifyUserUpdate(
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
+488
View File
@@ -0,0 +1,488 @@
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 } 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';
const MAX_ACCOUNT_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 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');
}
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 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,
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);
}
+350 -13
View File
@@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
@@ -9,6 +10,15 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// CONTRACT:
// users.master_password_hash is server-side login verification only. It does
// not decrypt vault data. Password changes must keep encrypted user key material,
// securityStamp, refresh-token invalidation, and client compatibility together.
// Password hints are non-secret reminders; never treat them as recovery secrets.
function looksLikeEncString(value: string): boolean {
if (!value) return false;
const firstDot = value.indexOf('.');
@@ -58,6 +68,77 @@ function normalizeTotpSecret(input: string): string {
return out;
}
function randomBase32Secret(length: number = 32): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let out = '';
for (const byte of bytes) {
out += TOTP_BASE32_ALPHABET[byte % TOTP_BASE32_ALPHABET.length];
}
return out;
}
function base64UrlEncodeBytes(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64UrlDecodeBytes(input: string): Uint8Array {
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
const binary = atob(base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
}
async function createTotpUserVerificationToken(env: Env, user: User, key: string): Promise<string> {
const payload = {
sub: user.id,
key,
stamp: user.securityStamp,
exp: Date.now() + TOTP_USER_VERIFICATION_TOKEN_TTL_MS,
};
const payloadB64 = base64UrlEncodeBytes(new TextEncoder().encode(JSON.stringify(payload)));
const signatureB64 = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
return `${payloadB64}.${signatureB64}`;
}
async function verifyTotpUserVerificationToken(env: Env, user: User, key: string, token: string): Promise<boolean> {
try {
const [payloadB64, signatureB64] = String(token || '').split('.');
if (!payloadB64 || !signatureB64) return false;
const expected = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
if (expected !== signatureB64) return false;
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecodeBytes(payloadB64))) as {
sub?: string;
key?: string;
stamp?: string;
exp?: number;
};
return (
payload.sub === user.id &&
payload.key === key &&
payload.stamp === user.securityStamp &&
typeof payload.exp === 'number' &&
payload.exp >= Date.now()
);
} catch {
return false;
}
}
function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
@@ -85,6 +166,23 @@ async function verifyUserSecret(
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function readBodyString(body: Record<string, unknown>, names: string[]): string {
for (const name of names) {
const value = body[name];
if (typeof value === 'string') return value;
}
return '';
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
return Object.fromEntries(formData.entries()) as Record<string, unknown>;
}
return await request.json();
}
function toProfile(user: User, env: Env): ProfileResponse {
void env;
const accountKeys = buildAccountKeys(user);
@@ -222,14 +320,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Registration is temporarily unavailable, retry once', 409);
}
await storage.setRegistered();
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.first_admin',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: now,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
}
@@ -254,14 +352,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is invalid or expired', 403);
}
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.invite',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }),
createdAt: now,
category: 'security',
level: 'info',
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
@@ -373,6 +471,18 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.profile.update',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
updatedMasterPasswordHint: true,
...auditRequestMetadata(request),
},
});
return jsonResponse(toProfile(user, env));
}
@@ -407,6 +517,18 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
@@ -456,6 +578,20 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.keys.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
updatedKey: !!body.key,
updatedPrivateKey: !!body.encryptedPrivateKey,
updatedPublicKey: !!body.publicKey,
...auditRequestMetadata(request),
},
});
return handleGetProfile(request, env, userId);
}
@@ -521,14 +657,15 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({
id: generateUUID(),
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: user.updatedAt,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return new Response(null, { status: 200 });
@@ -547,6 +684,164 @@ export async function handleGetTotpStatus(request: Request, env: Env, userId: st
});
}
function twoFactorProviderResponse(type: number, enabled: boolean): Record<string, unknown> {
return {
Enabled: enabled,
Type: type,
Object: 'twoFactorProvider',
};
}
function twoFactorAuthenticatorResponse(
enabled: boolean,
key: string,
userVerificationToken?: string
): Record<string, unknown> {
return {
Enabled: enabled,
Key: key,
UserVerificationToken: userVerificationToken ?? null,
Object: 'twoFactorAuthenticator',
};
}
// GET /api/two-factor
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
const data = user.totpSecret
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
: [];
return jsonResponse({
Data: data,
ContinuationToken: null,
Object: 'list',
});
}
// POST /api/two-factor/get-authenticator
export async function handleGetTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const key = normalizeTotpSecret(user.totpSecret || '') || randomBase32Secret();
const userVerificationToken = await createTotpUserVerificationToken(env, user, key);
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
}
// PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const token = readBodyString(body, ['token', 'Token']).trim();
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!key || !token || !userVerificationToken) {
return errorResponse('Key, token and userVerificationToken are required', 400);
}
if (!await verifyTotpUserVerificationToken(env, user, key, userVerificationToken)) {
return errorResponse('User verification failed.', 400);
}
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
user.totpSecret = key;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
}
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false;
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
if (!verified) return errorResponse('User verification failed.', 400);
user.totpSecret = null;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// disable: { enabled: false, masterPasswordHash: "..." }
@@ -582,6 +877,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
}
@@ -596,6 +901,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: false, object: 'twoFactor' });
}
@@ -634,7 +949,9 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
}
return jsonResponse({
Code: user.totpRecoveryCode,
code: user.totpRecoveryCode,
Object: 'twoFactorRecover',
object: 'twoFactorRecover',
});
}
@@ -666,7 +983,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) {
@@ -703,7 +1020,17 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'account.totp.recover',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({
success: true,
@@ -796,6 +1123,16 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
}
return jsonResponse({
+120 -13
View File
@@ -1,8 +1,9 @@
import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -24,16 +25,20 @@ async function writeAuditLog(
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
category: action.startsWith('admin.user.') ? 'security' : 'system',
level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
@@ -81,6 +86,106 @@ export async function handleAdminListUsers(
});
}
// 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();
return jsonResponse({ object: 'auditLogClear', deleted });
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
@@ -115,9 +220,9 @@ export async function handleAdminCreateInvite(
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours,
});
}, request);
return jsonResponse(toInviteResponse(request, invite), 201);
}
@@ -160,7 +265,7 @@ export async function handleAdminRevokeInvite(
return errorResponse('Invite not found or already inactive', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
return new Response(null, { status: 204 });
}
@@ -179,7 +284,7 @@ export async function handleAdminDeleteAllInvites(
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
});
}, request);
return jsonResponse({ deleted }, 200);
}
@@ -222,9 +327,10 @@ export async function handleAdminSetUserStatus(
if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id);
}
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
});
}, request);
return jsonResponse({
id: target.id,
@@ -280,9 +386,10 @@ export async function handleAdminDeleteUser(
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email,
});
targetEmail: target.email,
}, request);
return new Response(null, { status: 204 });
}
+34 -6
View File
@@ -10,7 +10,7 @@ import {
verifyAttachmentUploadToken,
verifyFileDownloadToken,
} from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
@@ -20,6 +20,7 @@ import {
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -30,6 +31,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
@@ -260,6 +282,7 @@ export async function handleGetAttachment(
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
// Generate short-lived download token
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
@@ -270,12 +293,12 @@ export async function handleGetAttachment(
return jsonResponse({
object: 'attachment',
id: attachment.id,
id: responseAttachment.id,
url: downloadUrl,
fileName: attachment.fileName,
key: attachment.key,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
fileName: responseAttachment.fileName,
key: responseAttachment.key,
size: String(Number(responseAttachment.size) || 0),
sizeName: responseAttachment.sizeName,
});
}
@@ -430,6 +453,11 @@ export async function handleDeleteAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
}
// Get updated cipher for response
+269
View File
@@ -0,0 +1,269 @@
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';
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 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: authRequest.masterPasswordHash,
MasterPasswordHash: authRequest.masterPasswordHash,
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;
}
}
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);
}
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 handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(id);
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.getAuthRequestById(id);
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 masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
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);
}
const updated = await storage.updateAuthRequestResponse(id, userId, {
approved,
responseDeviceIdentifier,
key,
masterPasswordHash,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id);
// 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));
}
+395 -238
View File
@@ -1,21 +1,20 @@
import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import {
type BackupArchiveBundle,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
parseBackupArchive,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
import {
type BackupDestinationRecord,
type BackupSettingsInput,
BACKUP_SCHEDULER_WINDOW_MINUTES,
type BackupSettings,
type WebDavBackupDestination,
getBackupLocalDateKey,
getDefaultBackupSettings,
getBackupSettingsRepairState,
hasBackupSlotBetween,
isBackupDueNow,
loadBackupSettings,
normalizeBackupSettingsInput,
normalizeImportedBackupSettings,
@@ -31,6 +30,7 @@ import {
} from '../services/backup-import';
import {
type RemoteBackupTransferSession,
type RemoteBackupFile,
createRemoteBackupTransferSession,
deleteRemoteBackupFile,
downloadRemoteBackupFile,
@@ -40,8 +40,10 @@ import {
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -53,16 +55,20 @@ async function writeAuditLog(
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
category: 'data',
level: action.endsWith('.failed') ? 'error' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
@@ -81,98 +87,6 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null
};
}
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
interface BackupRunnerLease {
token: string;
touch: () => Promise<void>;
release: () => Promise<void>;
}
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
const token = generateUUID();
const nowMs = Date.now();
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
const value = JSON.stringify({
token,
reason,
acquiredAt: new Date(nowMs).toISOString(),
touchedAt: new Date(nowMs).toISOString(),
expiresAtMs,
});
const result = await env.DB
.prepare(
`INSERT INTO config(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
)
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
.run();
if ((result.meta?.changes || 0) < 1) {
return null;
}
return {
token,
touch: async () => {
const nextNowMs = Date.now();
const nextValue = JSON.stringify({
token,
reason,
acquiredAt: new Date(nowMs).toISOString(),
touchedAt: new Date(nextNowMs).toISOString(),
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
});
await env.DB
.prepare(
`UPDATE config
SET value = ?
WHERE key = ?
AND json_extract(value, '$.token') = ?`
)
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
.run();
},
release: async () => {
await env.DB
.prepare(
`DELETE FROM config
WHERE key = ?
AND json_extract(value, '$.token') = ?`
)
.bind(BACKUP_RUNNER_LOCK_KEY, token)
.run();
},
};
}
async function withBackupRunnerLease<T>(
env: Env,
reason: string,
task: (keepAlive: () => Promise<void>) => Promise<T>
): Promise<T | null> {
const lease = await acquireBackupRunnerLease(env, reason);
if (!lease) return null;
let lastHeartbeatAt = 0;
const keepAlive = async () => {
const nowMs = Date.now();
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
lastHeartbeatAt = nowMs;
await lease.touch();
};
try {
await keepAlive();
return await task(keepAlive);
} finally {
await lease.release();
}
}
function ensureBackupBlobName(value: string): string {
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) {
@@ -192,6 +106,37 @@ interface RemoteAttachmentIndexPayload {
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
}
const REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT = 50;
const REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE = 6;
const REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE = 18;
const REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE = 40;
const REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE = 40;
function countRemotePathSegments(value: string): number {
return String(value || '').replace(/\\/g, '/').split('/').filter(Boolean).length;
}
function getRemoteAttachmentSyncBatchSize(destination: BackupDestinationRecord): number {
if (destination.type === 's3') {
return REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE;
}
const remotePath = String((destination.destination as WebDavBackupDestination).remotePath || '');
const fixedWebDavDirectoryCalls = countRemotePathSegments(remotePath) + 1; // remotePath plus the shared "attachments" dir.
const available = REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT
- REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE
- fixedWebDavDirectoryCalls;
if (available < 2) {
throw new Error('WebDAV remote backup path is too deep for safe attachment batching');
}
return Math.max(1, Math.min(
REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE,
Math.floor(available / 2)
));
}
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
try {
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
@@ -247,7 +192,39 @@ async function saveRemoteAttachmentIndex(
});
}
async function executeConfiguredBackup(
async function uploadRemoteAttachmentChunk(
env: Env,
destination: BackupDestinationRecord,
attachments: Array<{ blobName: string }>
): Promise<void> {
if (!attachments.length) return;
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-sync');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/upload-attachment-chunk', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
destination,
attachments,
}),
});
if (!response.ok) {
let message = `Attachment sync failed: ${response.status}`;
try {
const payload = await response.json<{ error?: string }>();
if (payload?.error) {
message = payload.error;
}
} catch {
// Ignore JSON parse failures and preserve the status-based error.
}
throw new Error(message);
}
}
export async function executeConfiguredBackup(
env: Env,
storage: StorageService,
actorUserId: string | null,
@@ -263,7 +240,8 @@ async function executeConfiguredBackup(
done?: boolean;
ok?: boolean;
error?: string | null;
}) => Promise<void>) | null
}) => Promise<void>) | null,
auditMetadata?: Record<string, unknown> | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3;
const touchLease = async () => {
@@ -321,25 +299,20 @@ async function executeConfiguredBackup(
if (destination.includeAttachments) {
await touchLease();
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) {
const pendingAttachments = (archive.manifest.attachmentBlobs || [])
.filter((attachment) => remoteAttachmentIndex.get(attachment.blobName) !== attachment.sizeBytes);
const attachmentSyncBatchSize = getRemoteAttachmentSyncBatchSize(destination);
for (let i = 0; i < pendingAttachments.length; i += attachmentSyncBatchSize) {
await touchLease();
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
const chunk = pendingAttachments
.slice(i, i + attachmentSyncBatchSize)
.map((attachment) => ({ blobName: attachment.blobName }));
await uploadRemoteAttachmentChunk(env, destination, chunk);
}
const remotePath = `attachments/${attachment.blobName}`;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
if (pendingAttachments.length) {
for (const attachment of pendingAttachments) {
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await touchLease();
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
}
@@ -419,6 +392,7 @@ async function executeConfiguredBackup(
uploadVerificationAttempts: maxArchiveUploadAttempts,
prunedFileCount,
pruneError: pruneErrorMessage,
...(auditMetadata || {}),
});
await progress?.({
@@ -447,6 +421,7 @@ async function executeConfiguredBackup(
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
...(auditMetadata || {}),
});
await progress?.({
operation: 'backup-remote-run',
@@ -462,14 +437,293 @@ async function executeConfiguredBackup(
}
}
interface DurableBackupRunResponse {
result: {
fileName: string;
fileSize: number;
remotePath: string;
provider: string;
};
settings: BackupSettings;
}
async function runConfiguredBackupInDurableObject(
env: Env,
payload: {
actorUserId: string | null;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
targetDeviceIdentifier?: string | null;
trigger: 'manual' | 'scheduled';
}
): Promise<DurableBackupRunResponse | null> {
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/run-configured-backup', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify(payload),
});
if (response.status === 409) {
return null;
}
if (!response.ok) {
let message = `Backup run failed: ${response.status}`;
try {
const body = await response.json<{ error?: string }>();
if (body?.error) message = body.error;
} catch {
// Preserve the status-based message when the DO returns a non-JSON error.
}
throw new Error(message);
}
const body = await response.json<DurableBackupRunResponse>();
if (!body?.result || !body?.settings) {
throw new Error('Backup run response is invalid');
}
return body;
}
async function runScheduledBackupsInDurableObject(env: Env): Promise<void> {
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/run-scheduled-backups', {
method: 'POST',
});
if (response.status === 409) {
return;
}
if (!response.ok) {
let message = `Scheduled backup failed: ${response.status}`;
try {
const body = await response.json<{ error?: string }>();
if (body?.error) message = body.error;
} catch {
// Preserve the status-based message when the DO returns a non-JSON error.
}
throw new Error(message);
}
}
async function downloadRemoteAttachmentViaDurableObject(
env: Env,
destination: BackupDestinationRecord,
blobName: string
): Promise<Uint8Array | null> {
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
destination,
blobName,
}),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Remote attachment download failed: ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
async function downloadRemoteAttachmentBatchViaDurableObject(
env: Env,
destination: BackupDestinationRecord,
blobNames: string[]
): Promise<Map<string, Uint8Array>> {
const names = Array.from(new Set(blobNames.map((blobName) => String(blobName || '').trim()).filter(Boolean)));
const result = new Map<string, Uint8Array>();
if (!names.length) return result;
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment-batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
destination,
blobNames: names,
}),
});
if (!response.ok) {
throw new Error(`Remote attachment batch download failed: ${response.status}`);
}
const files = unzipSync(new Uint8Array(await response.arrayBuffer()));
const manifestBytes = files['manifest.json'];
if (!manifestBytes) return result;
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
entries?: Array<{ blobName?: string; path?: string }>;
};
for (const entry of manifest.entries || []) {
const blobName = String(entry.blobName || '').trim();
const path = String(entry.path || '').trim();
const bytes = path ? files[path] : null;
if (blobName && bytes) {
result.set(blobName, bytes);
}
}
return result;
}
function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): string[] {
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
const refs = new Map(
(parsed.payload.manifest.attachmentBlobs || [])
.map((item) => [`${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}`, item])
);
const names: string[] = [];
const seen = new Set<string>();
for (const row of parsed.payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim();
if (blobName && !seen.has(blobName)) {
seen.add(blobName);
names.push(blobName);
}
}
return names;
}
function toImportStatusCode(message: string): number {
const lower = message.toLowerCase();
if (lower.includes('checksum')) return 400;
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409;
return 500;
}
export async function importAndAuditRemoteBackupFile(
env: Env,
storage: StorageService,
actorUserId: string,
remoteFile: RemoteBackupFile,
destination: BackupDestinationRecord,
remotePath: string,
replaceExisting: boolean,
checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null
): Promise<BackupImportExecutionResult> {
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUserId,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
const result = await importRemoteBackupArchiveBytes(
remoteFile.bytes,
env,
actorUserId,
replaceExisting,
{
loadAttachment: async (blobName) => {
const normalized = String(blobName || '').trim();
if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) {
return externalAttachmentCache.get(normalized) || null;
}
const start = Math.max(0, externalAttachmentBlobNames.indexOf(normalized));
const batchNames = externalAttachmentBlobNames
.slice(start, start + REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE)
.filter((name) => !externalAttachmentCache.has(name));
if (!batchNames.includes(normalized)) {
batchNames.unshift(normalized);
}
try {
const batch = await downloadRemoteAttachmentBatchViaDurableObject(env, destination, batchNames);
for (const name of batchNames) {
externalAttachmentCache.set(name, batch.get(name) || null);
}
} catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
}
return externalAttachmentCache.get(normalized) || null;
},
},
progress,
restoreFileName
);
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: result.result.imported.users,
ciphers: result.result.imported.ciphers,
attachments: result.result.imported.attachmentFiles,
skippedAttachments: result.result.skipped.attachments,
skippedReason: result.result.skipped.reason,
replaceExisting,
...getBackupDestinationSummary(destination),
remotePath,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
checksumMismatchAccepted,
...(auditMetadata || {}),
});
return result;
}
async function restoreRemoteBackupInDurableObject(
env: Env,
payload: {
actorUserId: string;
allowChecksumMismatch?: boolean;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
path: string;
replaceExisting?: boolean;
targetDeviceIdentifier?: string | null;
}
): Promise<BackupImportExecutionResult['result'] | null> {
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
const response = await stub.fetch('https://backup-transfer/internal/restore-remote-backup', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify(payload),
});
if (response.status === 409) {
return null;
}
if (!response.ok) {
let message = `Remote backup restore failed: ${response.status}`;
try {
const body = await response.json<{ error?: string }>();
if (body?.error) message = body.error;
} catch {
// Preserve the status-based message when the DO returns a non-JSON error.
}
throw new Error(message);
}
return response.json<BackupImportExecutionResult['result']>();
}
async function runImportAndAudit(
env: Env,
request: Request,
@@ -509,35 +763,12 @@ async function runImportAndAudit(
skippedReason: imported.result.skipped.reason,
replaceExisting,
...metadata,
});
}, request);
return imported;
}
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
const storage = new StorageService(env.DB);
let scanStartMs = Date.now();
while (true) {
await keepAlive();
const settings = await loadBackupSettings(storage, 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) {
return;
}
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await keepAlive();
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
}
}
});
await runScheduledBackupsInDurableObject(env);
}
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -582,7 +813,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
}, request);
return jsonResponse(next);
}
@@ -632,7 +863,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
});
}, request);
return jsonResponse(next);
}
@@ -649,32 +880,12 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
return errorResponse('Backup run payload is invalid', 400);
}
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const progress = async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
};
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
const storage = new StorageService(env.DB);
const result = await executeConfiguredBackup(
env,
storage,
actorUser.id,
'manual',
body?.destinationId || null,
keepAlive,
progress
);
const settings = await loadBackupSettings(storage, env, 'UTC');
return { result, settings };
const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request),
destinationId: body?.destinationId || null,
targetDeviceIdentifier: String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null,
trigger: 'manual',
});
if (!outcome) {
return errorResponse('Another backup run is already in progress', 409);
@@ -773,7 +984,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
remotePath: path,
});
}, request);
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
@@ -790,76 +1001,22 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
return errorResponse('Remote restore payload is invalid', 400);
}
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const restoreFileNameFromPath = path.split('/').pop() || path;
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
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',
const imported = await restoreRemoteBackupInDurableObject(env, {
actorUserId: actorUser.id,
allowChecksumMismatch: !!body.allowChecksumMismatch,
auditMetadata: auditRequestMetadata(request),
destinationId: body.destinationId || null,
path,
replaceExisting: !!body.replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return errorResponse('Remote backup file checksum does not match its filename', 400);
}
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
const imported = await (async () => {
const storage = new StorageService(env.DB);
const result = await importRemoteBackupArchiveBytes(
remoteFile.bytes,
env,
actorUser.id,
!!body.replaceExisting,
{
loadAttachment: async (blobName) => {
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
return file?.bytes || null;
},
},
progress,
restoreFileName
);
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: result.result.imported.users,
ciphers: result.result.imported.ciphers,
attachments: result.result.imported.attachmentFiles,
skippedAttachments: result.result.skipped.attachments,
skippedReason: result.result.skipped.reason,
replaceExisting: !!body.replaceExisting,
...getBackupDestinationSummary(destination),
remotePath: path,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
checksumMismatchAccepted: !checksumOk,
targetDeviceIdentifier,
});
return result;
})();
return jsonResponse(imported.result);
if (!imported) {
return errorResponse('Another backup or restore run is already in progress', 409);
}
return jsonResponse(imported);
} catch (error) {
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
return errorResponse(message, toImportStatusCode(message));
@@ -933,7 +1090,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
attachments: archive.manifest.tableCounts.attachments,
compressedBytes: archive.bytes.byteLength,
includesAttachments: archive.manifest.includes.attachments,
});
}, request);
return new Response(archive.bytes, {
status: 200,
+423 -30
View File
@@ -17,6 +17,24 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
// CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
// unknown/future client fields by default, then override only server-owned
// fields. Any change to cipher response shape must be checked against /api/sync,
// attachments, import/export, and current official clients.
export interface CipherResponseOptions {
preserveRepairableUris?: boolean;
}
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
return request.headers.get('X-NodeWarden-Web') === '1';
}
function cipherResponseOptionsForRequest(request: Request): CipherResponseOptions {
return { preserveRepairableUris: shouldPreserveRepairableCipherUris(request) };
}
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
@@ -78,6 +96,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return cipher;
}
async function writeCipherAudit(
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: 'cipher',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
@@ -102,15 +141,32 @@ function optionalEncString(value: unknown): string | null {
return isValidEncString(value) ? value.trim() : null;
}
function optionalEncStringWithin(value: unknown, maxLength: number): string | null {
const normalized = optionalEncString(value);
if (!normalized) return null;
return normalized.length <= maxLength ? normalized : null;
}
function shouldAcceptCipherKey(value: unknown): boolean {
return value == null || value === '' || isValidEncString(value);
}
function normalizeCipherKeyForStorage(value: unknown): string | null {
return optionalEncString(value);
}
function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined,
encryptedKeys: readonly string[]
encryptedKeys: readonly string[] | Record<string, number>
): T | null {
if (!source || typeof source !== 'object') return source ?? null;
const next: Record<string, any> = { ...source };
for (const key of encryptedKeys) {
const entries = Array.isArray(encryptedKeys)
? encryptedKeys.map((key) => [key, 10000] as const)
: Object.entries(encryptedKeys);
for (const [key, maxLength] of entries) {
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
next[key] = optionalEncString(next[key]);
next[key] = optionalEncStringWithin(next[key], maxLength);
}
return next as T;
}
@@ -134,20 +190,92 @@ export function normalizeCipherLoginForStorage(login: any): any {
};
}
export function normalizeCipherLoginForCompatibility(login: any): any {
export function normalizeCipherLoginForCompatibility(
login: any,
requiresUriChecksum: boolean = false,
preserveRepairableUris: boolean = false
): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
const next = sanitizeEncryptedObject(normalized, {
username: 1000,
password: 5000,
totp: 1000,
uri: 10000,
});
if (!next) return null;
next.uris = Array.isArray(next.uris)
? next.uris
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
: null;
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
requiresUriChecksum,
preserveRepairableUris,
});
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next;
}
function normalizeCipherLoginUrisForCompatibility(
uris: any,
options: { requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {}
): any[] | null {
if (!Array.isArray(uris) || uris.length === 0) return null;
const out: any[] = [];
for (const uri of uris) {
if (!uri || typeof uri !== 'object') continue;
const next = sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']);
if (!next) continue;
const hasUri = isValidEncString(next.uri);
const hasChecksum = isValidEncString(next.uriChecksum);
const hasMatch = next.match != null;
if (hasUri && String(next.uri).trim().length > 10000) continue;
if (hasChecksum && String(next.uriChecksum).trim().length > 10000) {
next.uriChecksum = null;
}
if (hasUri && isValidEncString(next.uriChecksum)) {
out.push(next);
continue;
}
if (hasUri && !hasChecksum) {
// Official Bitwarden treats UriChecksum as nullable encrypted metadata.
// Keep the URI intact and let clients that can repair checksums do so.
out.push({ ...next, uriChecksum: null });
continue;
}
if (hasChecksum || hasMatch) {
out.push(next);
}
}
return out.length ? out : null;
}
export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null {
if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.';
if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.';
const login = cipher.login as any;
if (login && typeof login === 'object') {
if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.';
if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.';
if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.';
if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (Array.isArray(login.uris)) {
for (const uri of login.uris) {
if (!uri || typeof uri !== 'object') continue;
if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
}
}
}
return null;
}
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null;
const requiredEncryptedKeys = [
@@ -218,6 +346,14 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
};
}
function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecureNote | null {
if (!secureNote || typeof secureNote !== 'object') return null;
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
return {
type: Number.isFinite(type) ? type : 0,
};
}
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
@@ -236,6 +372,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
return formatted.length ? formatted : null;
}
function formatAttachmentSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
interface IncomingAttachmentMetadata {
id: string;
fileName?: unknown;
key?: unknown;
fileSize?: unknown;
hasFileName: boolean;
hasKey: boolean;
hasFileSize: boolean;
}
function readIncomingAttachmentMetadataMap(
value: unknown,
options: { legacyFileNameMap?: boolean } = {}
): IncomingAttachmentMetadata[] {
if (!value || typeof value !== 'object') return [];
const out: IncomingAttachmentMetadata[] = [];
if (Array.isArray(value)) {
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const id = String(row.id ?? row.Id ?? '').trim();
if (!id) continue;
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
const key = getAliasedProp(row, ['key', 'Key']);
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
out.push({
id,
fileName: fileName.value,
key: key.value,
fileSize: fileSize.value,
hasFileName: fileName.present,
hasKey: key.present,
hasFileSize: fileSize.present,
});
}
return out;
}
for (const [rawId, rawValue] of Object.entries(value as Record<string, unknown>)) {
const id = String(rawId || '').trim();
if (!id) continue;
if (options.legacyFileNameMap && (typeof rawValue === 'string' || rawValue == null)) {
out.push({
id,
fileName: rawValue,
key: undefined,
fileSize: undefined,
hasFileName: rawValue != null,
hasKey: false,
hasFileSize: false,
});
continue;
}
if (!rawValue || typeof rawValue !== 'object') continue;
const row = rawValue as Record<string, unknown>;
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
const key = getAliasedProp(row, ['key', 'Key']);
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
out.push({
id,
fileName: fileName.value,
key: key.value,
fileSize: fileSize.value,
hasFileName: fileName.present,
hasKey: key.present,
hasFileSize: fileSize.present,
});
}
return out;
}
function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata[] {
const merged = new Map<string, IncomingAttachmentMetadata>();
const legacy = getAliasedProp(source, ['attachments', 'Attachments']);
const current = getAliasedProp(source, ['attachments2', 'Attachments2']);
if (legacy.present) {
for (const item of readIncomingAttachmentMetadataMap(legacy.value, { legacyFileNameMap: true })) {
merged.set(item.id, item);
}
}
if (current.present) {
for (const item of readIncomingAttachmentMetadataMap(current.value)) {
const previous = merged.get(item.id);
merged.set(item.id, {
id: item.id,
fileName: item.hasFileName ? item.fileName : previous?.fileName,
key: item.hasKey ? item.key : previous?.key,
fileSize: item.hasFileSize ? item.fileSize : previous?.fileSize,
hasFileName: item.hasFileName || previous?.hasFileName || false,
hasKey: item.hasKey || previous?.hasKey || false,
hasFileSize: item.hasFileSize || previous?.hasFileSize || false,
});
}
}
return [...merged.values()];
}
function hasIncomingAttachmentMetadata(source: any): boolean {
return readIncomingAttachmentMetadata(source).length > 0;
}
async function syncIncomingAttachmentMetadata(
storage: StorageService,
cipherId: string,
cipherData: any
): Promise<void> {
const incoming = readIncomingAttachmentMetadata(cipherData);
if (!incoming.length) return;
const currentById = new Map((await storage.getAttachmentsByCipher(cipherId)).map((attachment) => [attachment.id, attachment]));
for (const item of incoming) {
const attachment = currentById.get(item.id);
if (!attachment) continue;
let changed = false;
if (item.hasFileName) {
const fileName = String(item.fileName || '').trim();
if (isValidEncString(fileName) && fileName !== attachment.fileName) {
attachment.fileName = fileName;
changed = true;
}
}
if (item.hasKey) {
const key = optionalEncString(item.key);
if (key !== attachment.key) {
attachment.key = key;
changed = true;
}
}
if (item.hasFileSize) {
const size = Number(item.fileSize);
if (Number.isFinite(size) && size >= 0 && size !== Number(attachment.size || 0)) {
attachment.size = size;
attachment.sizeName = formatAttachmentSize(size);
changed = true;
}
}
if (changed) {
await storage.saveAttachment(attachment);
}
}
}
export function applyCipherEmbeddedAttachmentMetadata(cipherData: any, attachments: Attachment[]): Attachment[] {
const incoming = readIncomingAttachmentMetadata(cipherData);
if (!incoming.length || !attachments.length) return attachments;
const incomingById = new Map(incoming.map((item) => [item.id, item]));
return attachments.map((attachment) => {
const item = incomingById.get(attachment.id);
if (!item) return attachment;
const next: Attachment = { ...attachment };
if (item.hasFileName) {
const fileName = String(item.fileName || '').trim();
if (isValidEncString(fileName)) {
next.fileName = fileName;
}
}
if (item.hasKey) {
next.key = optionalEncString(item.key);
}
if (item.hasFileSize) {
const size = Number(item.fileSize);
if (Number.isFinite(size) && size >= 0) {
next.size = size;
next.sizeName = formatAttachmentSize(size);
}
}
return next;
});
}
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
if (!Array.isArray(fields) || fields.length === 0) return null;
const out = fields
@@ -275,12 +601,25 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean
// survive a round-trip without code changes.
export function cipherToResponse(
cipher: Cipher,
attachments: Attachment[] = []
attachments: Attachment[] = [],
options: CipherResponseOptions = {}
): CipherResponse {
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
const responseCipherKey = optionalEncString(cipher.key);
const normalizedLogin = normalizeCipherLoginForCompatibility(
(passthrough as any).login ?? null,
!!responseCipherKey,
!!options.preserveRepairableUris
);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, {
cardholderName: 1000,
brand: 1000,
number: 1000,
expMonth: 1000,
expYear: 1000,
code: 1000,
});
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title',
'firstName',
@@ -302,6 +641,10 @@ export function cipherToResponse(
'licenseNumber',
]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
return {
// Pass through ALL stored cipher fields (known + unknown)
@@ -323,16 +666,18 @@ export function cipherToResponse(
},
object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments),
attachments: formatAttachments(responseAttachments),
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes),
login: normalizedLogin,
card: normalizedCard,
identity: normalizedIdentity,
secureNote: normalizedSecureNote,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey,
key: optionalEncString(cipher.key),
key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null,
};
}
@@ -368,10 +713,11 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
);
// Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request);
const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions));
}
return jsonResponse({
@@ -391,8 +737,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
}
const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse(
cipherToResponse(cipher, attachments)
cipherToResponse(cipher, attachments, responseOptions)
);
}
@@ -425,6 +772,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
// then override only server-controlled fields.
@@ -442,7 +793,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
deletedAt: null,
};
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
cipher.key = normalizeCipherKeyForStorage(createKey.present ? createKey.value : cipher.key);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
@@ -452,6 +803,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -462,9 +815,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse(
cipherToResponse(cipher, []),
cipherToResponse(cipher, [], responseOptions),
200
);
}
@@ -497,8 +851,16 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
const preserveRevisionDate =
shouldPreserveRepairableCipherUris(request)
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
@@ -506,9 +868,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns)
...cipherData, // overlay all client data (including new/unknown fields)
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
// Server-controlled fields (never from client)
id: existingCipher.id,
userId: existingCipher.userId,
@@ -516,7 +879,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(),
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
@@ -524,7 +887,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
}
if (incomingKey.present) {
cipher.key = incomingKey.value ?? null;
const normalizedIncomingKey = normalizeCipherKeyForStorage(incomingKey.value);
cipher.key = normalizedIncomingKey || normalizeCipherKeyForStorage(existingCipher.key);
} else {
cipher.key = normalizeCipherKeyForStorage(existingCipher.key);
}
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
@@ -546,6 +912,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.fields = null;
}
normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
@@ -553,13 +921,15 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404);
}
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse(
cipherToResponse(cipher, attachments)
cipherToResponse(cipher, attachments, responseOptions)
);
}
@@ -579,9 +949,14 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return jsonResponse(
cipherToResponse(cipher, [])
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
);
}
@@ -603,6 +978,12 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
compat: true,
});
return new Response(null, { status: 204 });
}
@@ -624,6 +1005,11 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return new Response(null, { status: 204 });
}
@@ -645,7 +1031,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
);
}
@@ -684,7 +1070,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
);
}
@@ -728,7 +1114,7 @@ async function buildCipherListResponse(
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request))
),
object: 'list',
continuationToken: null,
@@ -761,7 +1147,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
);
}
@@ -783,7 +1169,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments)
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
);
}
@@ -853,6 +1239,9 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length,
});
}
return new Response(null, { status: 204 });
@@ -912,6 +1301,10 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length,
requestedCount: ids.length,
});
}
return new Response(null, { status: 204 });
+93
View File
@@ -1,11 +1,15 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
@@ -265,9 +269,50 @@ export async function handleRevokeTrustedDevice(
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
// POST /api/devices/authorized/:deviceIdentifier/permanent
// Upgrades an existing active 2FA remember-token record to permanent trust.
export async function handleTrustDevicePermanently(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
updated,
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
});
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
@@ -284,8 +329,18 @@ export async function handleDeleteDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
@@ -309,6 +364,15 @@ export async function handleUpdateDeviceName(
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.name.update',
category: 'device',
level: 'info',
targetType: 'device',
targetId: normalized,
metadata: { name, ...auditRequestMetadata(request) },
});
return jsonResponse(buildDeviceResponse(device));
}
@@ -327,7 +391,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
@@ -419,6 +493,15 @@ export async function handleUntrustDevices(
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
@@ -458,8 +541,18 @@ export async function handleDeactivateDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
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 });
}
+85
View File
@@ -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
));
}
+28
View File
@@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -15,6 +16,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeFolderAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'folder',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
return {
@@ -134,6 +156,9 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 });
}
@@ -157,6 +182,9 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
}
return new Response(null, { status: 204 });
+289 -29
View File
@@ -14,15 +14,22 @@ import {
buildAccountKeys,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import {
assertAccountPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null): string | null {
@@ -32,6 +39,17 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null;
}
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
}
@@ -60,6 +78,14 @@ function constantTimeEquals(a: string, b: string): boolean {
return diff === 0;
}
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) {
const value = body[name];
if (value != null) return value;
}
return undefined;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:';
const parts = [
@@ -114,12 +140,13 @@ function buildPreloginResponse(
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
// parse the challenge if an unknown recovery provider key such as "8" is included.
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, { Email: null }> = {};
for (const provider of providers) providers2[provider] = { Email: null };
const customResponse = {
TwoFactorProviders: providers,
TwoFactorProviders2: providers2,
@@ -212,10 +239,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Login with password
const email = body.username?.toLowerCase();
const passwordHash = body.password;
const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = `${clientIdentifier}:${email}`;
const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
@@ -240,11 +268,57 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
let validatedAuthRequestId: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
authRequest.type === 0 &&
authRequest.approved === true &&
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
}
} else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
}
if (!valid) {
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return recordFailedLoginAndBuildResponse(
rateLimit,
loginIdentifier,
@@ -256,7 +330,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -266,7 +339,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
return twoFactorRequiredResponse('Two factor required.');
}
let passedByRememberToken = false;
@@ -281,7 +354,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
return twoFactorRequiredResponse('Two factor required.');
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
@@ -290,7 +363,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
@@ -320,10 +393,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Persist device only after successful password + (optional) 2FA verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
@@ -336,11 +406,29 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
if (validatedAuthRequestId) {
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
}
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -373,6 +461,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier;
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
deviceResponse = JSON.parse(deviceResponse);
} catch {
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
}
}
if (!token || !deviceResponse) {
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
}
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
asserted = await assertAccountPasskeyCredential(request, env, storage, {
token,
deviceResponse,
scope: 'Authentication',
});
} catch (error) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: null,
action: 'auth.passkey.login.failed',
category: 'auth',
level: 'warn',
targetType: 'accountPasskey',
targetId: null,
metadata: {
grantType,
reason: error instanceof Error ? error.message : 'assertion_failed',
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
}
const { user, credential } = asserted;
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.passkey.login.success',
category: 'auth',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'client_credentials') {
// Login with client credentials
const clientId = body.client_id;
@@ -380,7 +588,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`;
const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400);
@@ -404,19 +612,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_api_key',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful client credential verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
@@ -434,6 +665,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -538,8 +784,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
if (!result) {
const result = await auth.refreshAccessTokenDetailed(refreshToken);
if (!result.ok) {
await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null,
action: `auth.refresh.failed.${result.reason}`,
category: 'auth',
level: 'warn',
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
targetId: result.deviceIdentifier ?? null,
metadata: {
grantType,
reason: result.reason,
webSession: shouldUseWebSession(request),
...auditRequestMetadata(request),
},
});
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null)
+7 -3
View File
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
@@ -19,7 +19,7 @@ interface CiphersImportRequest {
sshKey?: any | null;
key?: string | null;
login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null;
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
username?: string | null;
password?: string | null;
totp?: string | null;
@@ -195,7 +195,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
uris: login.uris?.map((u: any) => ({
...u,
uri: u.uri ?? null,
uriChecksum: null,
uriChecksum: u.uriChecksum ?? null,
match: u.match ?? null,
})) || null,
totp: login.totp ?? null,
@@ -252,6 +252,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) {
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
}
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
+15
View File
@@ -56,3 +56,18 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
}
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
if (!authRequestId) return errorResponse('Token is required', 400);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const 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));
}
+38 -3
View File
@@ -29,6 +29,28 @@ import {
setSendPassword,
validateDeletionDate,
} from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
async function writeSendAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'send',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
async function processSendFileUpload(
request: Request,
@@ -602,7 +624,6 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
}
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -620,6 +641,10 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 });
}
@@ -651,13 +676,16 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
}
return new Response(null, { status: 200 });
}
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -669,12 +697,15 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
@@ -687,6 +718,10 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
+47 -13
View File
@@ -1,7 +1,7 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import {
@@ -9,11 +9,26 @@ import {
buildUserDecryptionCompat,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
// CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
// Filtering invalid cipher responses here protects clients from stored rows that
// would otherwise make official apps fail after an HTTP 200 sync.
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
function buildSyncCacheRequest(
request: Request,
userId: string,
revisionDate: string,
accountPasskeyCacheTag: string,
excludeDomains: boolean,
excludeSends: boolean,
preserveRepairableUris: boolean
): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
@@ -37,27 +52,43 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
const [revisionDate, accountPasskeys] = await Promise.all([
storage.getRevisionDate(userId),
storage.getAccountPasskeyCredentialsByUserId(userId),
]);
const accountPasskeyCacheTag = accountPasskeys
.map((credential) => [
credential.id,
credential.updatedAt,
credential.supportsPrf ? '1' : '0',
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
].join(':'))
.join(',');
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
}
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const profile: ProfileResponse = {
id: user.id,
@@ -86,7 +117,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris });
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
@@ -111,17 +142,20 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
ciphers: cipherResponses,
domains: excludeDomains
? null
: {
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
},
: buildDomainsResponse(
domainSettings?.equivalentDomains || [],
domainSettings?.customEquivalentDomains || [],
domainSettings?.excludedGlobalEquivalentDomains || [],
{ omitExcludedGlobals: true }
),
policies: [],
sends: sendResponses,
UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions,
Object: 'userDecryption',
},
UserDecryptionOptions: userDecryptionOptions,
+23 -1
View File
@@ -1,5 +1,6 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { BackupTransferRunner } from './durable/backup-transfer-runner';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
@@ -31,13 +32,33 @@ function isWorkerHandledPath(path: string): boolean {
);
}
function addSearchIndexHeaders(request: Request, response: Response): Response {
const url = new URL(request.url);
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
const shouldNoIndex =
url.pathname === '/robots.txt' ||
contentType.includes('text/html');
if (!shouldNoIndex) return response;
const headers = new Headers(response.headers);
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null;
return env.ASSETS.fetch(request);
const response = await env.ASSETS.fetch(request);
return addSearchIndexHeaders(request, response);
}
async function ensureDatabaseInitialized(env: Env): Promise<void> {
@@ -107,3 +128,4 @@ export default {
};
export { NotificationsHub };
export { BackupTransferRunner };
+18
View File
@@ -7,6 +7,10 @@ import {
handleAdminRevokeInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
handleAdminListAuditLogs,
handleAdminGetAuditLogSettings,
handleAdminUpdateAuditLogSettings,
handleAdminClearAuditLogs,
} from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup';
@@ -21,6 +25,20 @@ export async function handleAdminRoute(
return handleAdminListUsers(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'GET') {
return handleAdminListAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'DELETE') {
return handleAdminClearAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs/settings') {
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
return null;
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse;
+78 -10
View File
@@ -11,6 +11,10 @@ import {
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleDisableTwoFactorProvider,
handleGetApiKey,
handleRotateApiKey,
} from './handlers/accounts';
@@ -65,6 +69,21 @@ import {
} from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices';
import { handleAdminRoute } from './router-admin';
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import {
handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential,
handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions,
handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys';
import {
handleGetAuthRequest,
handleListAuthRequests,
handleListPendingAuthRequests,
handleUpdateAuthRequest,
} from './handlers/auth-requests';
export async function handleAuthenticatedRoute(
request: Request,
@@ -110,6 +129,25 @@ export async function handleAuthenticatedRoute(
return handleGetTotpRecoveryCode(request, env, userId);
}
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if (path === '/api/two-factor/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/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId);
}
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
}
@@ -130,6 +168,28 @@ export async function handleAuthenticatedRoute(
return handleRotateApiKey(request, env, userId);
}
if (path === '/api/webauthn' || path === '/webauthn') {
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
}
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
}
const accountPasskeyDeleteMatch =
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
if (accountPasskeyDeleteMatch && method === 'POST') {
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
@@ -231,8 +291,21 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
if (authRequestMatch) {
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
@@ -297,14 +370,9 @@ export async function handleAuthenticatedRoute(
return null;
}
if (path === '/api/settings/domains') {
if (method === 'GET' || method === 'PUT' || method === 'POST') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
if (path === '/api/settings/domains' || path === '/settings/domains') {
if (method === 'GET') return handleGetDomains(env, userId);
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
return null;
}
+7
View File
@@ -11,6 +11,7 @@ import {
handleDeactivateDevice,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleTrustDevicePermanently,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceName,
@@ -44,6 +45,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
+141 -22
View File
@@ -9,19 +9,26 @@ import {
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import {
handleRegister,
handleGetPasswordHint,
handleRecoverTwoFactor,
} from './handlers/accounts';
import {
handleCreateAuthRequest,
handleGetAuthRequestResponse,
} from './handlers/auth-requests';
import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments';
import {
handleAnonymousNotificationsHub,
handleNotificationsHub,
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
@@ -31,6 +38,7 @@ export interface WebBootstrapResponse {
defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
registrationInviteRequired: boolean;
}
function isSameOriginWriteRequest(request: Request): boolean {
@@ -113,7 +121,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': true,
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
@@ -142,6 +150,18 @@ function normalizeIconHost(rawHost: string): string | null {
}
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
type IconSource = {
url: string;
rejectImage?: {
byteLength: number;
sha256: string;
};
headers?: HeadersInit;
};
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController();
@@ -161,51 +181,122 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }):
}
}
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
const upstreamSources: IconSource[] = [
{
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
headers: requestHeaders,
},
{
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
headers: requestHeaders,
rejectImage: {
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
},
{
url: `https://favicon.im/${encodedHost}`,
headers: requestHeaders,
},
{
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
headers: requestHeaders,
},
];
try {
for (const source of upstreamSources) {
try {
const resp = await fetchIconSource(source);
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
return new Response(resp.body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (!bytes) continue;
if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
}
return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch {
continue;
}
}
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} catch {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
}
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason =
!secret
@@ -215,11 +306,14 @@ export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
const storage = new StorageService(env.DB);
const userCount = await storage.getUserCount();
return {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
};
}
@@ -243,11 +337,13 @@ export async function handlePublicRoute(
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(buildWebBootstrapResponse(env));
return jsonResponse(await buildWebBootstrapResponse(env));
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
}
@@ -299,6 +395,19 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
}
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleCreateAuthRequest(request, env);
}
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
}
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
@@ -332,6 +441,12 @@ export async function handlePublicRoute(
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAccountPasskeyAssertionOptions(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
@@ -380,5 +495,9 @@ export async function handlePublicRoute(
if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env);
}
if (path === '/notifications/anonymous-hub' && method === 'GET') {
return handleAnonymousNotificationsHub(request, env);
}
return null;
}
+210
View File
@@ -0,0 +1,210 @@
import type { Env } from '../types';
import { generateUUID } from '../utils/uuid';
import { StorageService } from './storage';
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditEventInput {
actorUserId?: string | null;
action: string;
category: AuditLogCategory;
level?: AuditLogLevel;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
const MAX_METADATA_BYTES = 2048;
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
const AUDIT_CLEANUP_PROBABILITY = 0.02;
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
retentionDays: 90,
maxEntries: null,
};
let lastAuditCleanupAt = 0;
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
const ALLOWED_METADATA_KEYS = new Set([
'method',
'path',
'ip',
'userAgent',
'email',
'targetEmail',
'grantType',
'webSession',
'deviceIdentifier',
'deviceType',
'reason',
'status',
'verifyDevices',
'changed',
'removed',
'updated',
'deleted',
'removedTrusted',
'removedSessions',
'removedDevices',
'requested',
'count',
'requestedCount',
'type',
'folderId',
'cipherId',
'size',
'users',
'ciphers',
'attachments',
'skippedAttachments',
'skippedReason',
'replaceExisting',
'provider',
'prfStatus',
'fileName',
'fileBytes',
'bytes',
'compressedBytes',
'includesAttachments',
'destinationName',
'destinationId',
'destinationType',
'destinationCount',
'scheduledDestinationCount',
'retentionDays',
'maxEntries',
'remotePath',
'trigger',
'prunedFileCount',
'pruneError',
'uploadVerificationAttempts',
'error',
'expiresInHours',
'checksumMismatchAccepted',
]);
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
const parsed = Math.floor(Number(value));
return allowed.includes(parsed) ? parsed : null;
}
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
if (retentionDays) return { retentionDays, maxEntries: null };
if (maxEntries) return { retentionDays: null, maxEntries };
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
return { retentionDays: null, maxEntries: null };
}
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
return { retentionDays: null, maxEntries: null };
}
return {
...DEFAULT_AUDIT_LOG_SETTINGS,
};
}
export function auditRequestMetadata(request: Request): Record<string, unknown> {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
userAgent: request.headers.get('User-Agent') || null,
};
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
if (value === undefined || value === null || value === '') continue;
if (SENSITIVE_KEY_RE.test(key)) continue;
if (Array.isArray(value)) {
clean[key] = value.length;
continue;
}
if (typeof value === 'object') continue;
clean[key] = value;
}
return clean;
}
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
try {
return normalizeAuditLogSettings(JSON.parse(raw));
} catch {
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
}
}
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
const normalized = normalizeAuditLogSettings(settings);
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
await applyAuditLogRetention(storage, normalized);
return normalized;
}
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
const current = settings || await getAuditLogSettings(storage);
if (current.retentionDays) {
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
await storage.pruneAuditLogs(before);
}
if (current.maxEntries) {
await storage.pruneAuditLogsToMax(current.maxEntries);
}
}
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
const now = Date.now();
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
lastAuditCleanupAt = now;
await applyAuditLogRetention(storage);
}
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
const metadata = sanitizeMetadata(event.metadata || {});
let metadataJson = JSON.stringify(metadata);
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
metadataJson = JSON.stringify({ truncated: true });
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: event.actorUserId ?? null,
action: event.action,
category: event.category,
level: event.level || 'info',
targetType: event.targetType ?? null,
targetId: event.targetId ?? null,
metadata: metadataJson,
createdAt: new Date().toISOString(),
});
await maybePruneAuditLogs(storage);
}
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
try {
await insertAuditEvent(storage, event);
} catch (error) {
console.error('audit log write failed', error);
}
}
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
await writeAuditEvent(new StorageService(env.DB), event);
}
+66 -22
View File
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry {
@@ -23,6 +24,22 @@ export interface VerifiedAccessContext {
user: User;
}
export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired'
| 'user_missing'
| 'user_inactive'
| 'device_missing'
| 'device_session_mismatch';
export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
userId?: string | null;
deviceIdentifier?: string | null;
};
export class AuthService {
private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
@@ -32,6 +49,25 @@ export class AuthService {
this.storage = new StorageService(env.DB);
}
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId);
if (!cached) return undefined;
@@ -98,7 +134,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
@@ -116,20 +152,17 @@ export class AuthService {
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary);
return SERVER_HASH_PREFIX + btoa(binary);
}
// Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$".
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
if (email && storedHash.startsWith('$s$')) {
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
return this.constantTimeEquals(inputHash, storedHash);
}
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
}
// Legacy path: direct constant-time comparison of raw client hashes.
return this.constantTimeEquals(inputHash, storedHash);
}
private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
@@ -204,34 +237,45 @@ export class AuthService {
}
// Refresh access token
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null;
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(record.userId);
if (!user) return null;
if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) {
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const accessToken = await this.generateAccessToken(user, device);
return { accessToken, user, device };
return { ok: true, accessToken, user, device };
}
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const result = await this.refreshAccessTokenDetailed(refreshToken);
return result.ok ? result : null;
}
}
+94 -4
View File
@@ -1,14 +1,28 @@
import { zipSync, unzipSync } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import {
getAttachmentObjectKey,
getBlobStorageKind,
} from './blob-store';
// CONTRACT:
// This file defines the exported instance-backup archive shape. Keep it in lock
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
//
// WHEN CHANGING THIS:
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json.
// - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance.
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Worker-side backup export must stay well below Cloudflare CPU limits.
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
@@ -48,10 +62,13 @@ export interface BackupPayload {
db: {
config: SqlRow[];
users: SqlRow[];
domain_settings: SqlRow[];
user_revisions: SqlRow[];
folders: SqlRow[];
ciphers: SqlRow[];
attachments: SqlRow[];
webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
};
}
@@ -89,6 +106,23 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
return (result.results || []).map((row) => ({ ...row }));
}
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
const sanitized: SqlRow[] = [];
for (const row of rows) {
const key = String(row.key || '').trim();
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
continue;
}
sanitized.push({ ...row });
}
return sanitized;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
@@ -264,9 +298,12 @@ export function validateBackupPayloadContents(
const configRows = ensureRowArray(payload.db.config, 'config');
const userRows = ensureRowArray(payload.db.users, 'users');
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
const folderRows = ensureRowArray(payload.db.folders, 'folders');
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -294,6 +331,18 @@ export function validateBackupPayloadContents(
}
}
const domainSettingUserIds = new Set<string>();
for (const row of domainSettingsRows) {
const userId = String(row.user_id || '').trim();
if (!userId || !userIds.has(userId)) {
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
}
if (domainSettingUserIds.has(userId)) {
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
}
domainSettingUserIds.add(userId);
}
const folderIds = new Set<string>();
for (const row of folderRows) {
const id = String(row.id || '').trim();
@@ -327,6 +376,37 @@ export function validateBackupPayloadContents(
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
}
}
const accountPasskeyIds = new Set<string>();
const accountPasskeyCredentialIds = new Set<string>();
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
throw new Error('Backup archive contains an invalid account passkey row');
}
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId);
}
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
}
export async function buildBackupArchive(
@@ -345,14 +425,18 @@ export async function buildBackupArchive(
includeAttachments,
});
const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
const cipherId = String(row.cipher_id || '').trim();
@@ -371,12 +455,15 @@ export async function buildBackupArchive(
appVersion: APP_VERSION,
storageKind: getBlobStorageKind(env),
tableCounts: {
config: configRows.length,
config: exportedConfigRows.length,
users: userRows.length,
domain_settings: domainSettingsRows.length,
user_revisions: revisionRows.length,
folders: folderRows.length,
ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
},
includes: {
attachments: includeAttachments,
@@ -392,12 +479,15 @@ export async function buildBackupArchive(
const files: Record<string, Uint8Array> = {
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
'db.json': encoder.encode(JSON.stringify({
config: configRows,
config: exportedConfigRows,
users: userRows,
domain_settings: domainSettingsRows,
user_revisions: revisionRows,
folders: folderRows,
ciphers: cipherRows,
attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)),
};
+6 -19
View File
@@ -16,6 +16,7 @@ import {
type BackupRuntimeState,
type BackupScheduleConfig,
type BackupSettings,
type S3BackupAddressingStyle,
type S3BackupDestination,
type WebDavBackupDestination,
createBackupRandomId,
@@ -35,6 +36,7 @@ export type {
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination,
WebDavBackupDestination,
} from '../../shared/backup-schema';
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket);
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
const addressingStyle: S3BackupAddressingStyle =
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto';
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket,
addressingStyle,
region,
accessKeyId,
secretAccessKey,
@@ -409,13 +415,6 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
}
@@ -442,12 +441,6 @@ export async function normalizeImportedBackupSettingsValue(
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
@@ -455,12 +448,6 @@ export async function normalizeImportedBackupSettingsValue(
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
}
+75 -1
View File
@@ -8,11 +8,24 @@ import {
validateBackupPayloadContents,
} from './backup-archive';
// CONTRACT:
// Restore is intentionally whitelist-based. Old backups may contain retired
// fields, but only the columns listed here are imported. Keep this file in sync
// with src/services/backup-archive.ts whenever backup contents change.
//
// WHEN CHANGING THIS:
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
// shadow-table count validation, insert column lists, and frontend import
// count types together.
// - Do not import users.api_key, even if an older backup contains it.
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'domain_settings'
| 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials'
| 'folders'
| 'ciphers'
| 'attachments';
@@ -20,7 +33,10 @@ type BackupTableName =
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'domain_settings',
'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials',
'folders',
'ciphers',
'attachments',
@@ -35,7 +51,10 @@ export interface BackupImportResultBody {
imported: {
config: number;
users: number;
domainSettings: number;
userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number;
folders: number;
ciphers: number;
attachments: number;
@@ -155,6 +174,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM attachments',
'DELETE FROM ciphers',
'DELETE FROM folders',
'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings',
'DELETE FROM user_revisions',
'DELETE FROM users',
'DELETE FROM config',
@@ -276,7 +298,10 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
...row,
verify_devices: row.verify_devices ?? 1,
})),
domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
@@ -594,7 +619,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements(
db,
tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
payload.users || []
)
);
@@ -603,6 +628,37 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
tableName('user_revisions'),
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
);
await runInsertBatch(
db,
tableName('domain_settings'),
buildInsertStatements(
db,
tableName('domain_settings'),
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
payload.domain_settings || [],
true
)
);
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch(
db,
tableName('webauthn_credentials'),
buildInsertStatements(
db,
tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || []
)
);
await runInsertBatch(
db,
tableName('folders'),
@@ -669,7 +725,10 @@ export async function importBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
@@ -690,7 +749,10 @@ export async function importBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -729,7 +791,10 @@ export async function importBackupArchiveBytes(
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -804,7 +869,10 @@ export async function importRemoteBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
@@ -825,7 +893,10 @@ export async function importRemoteBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -870,7 +941,10 @@ export async function importRemoteBackupArchiveBytes(
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
+30 -3
View File
@@ -1,5 +1,17 @@
import type { Env, User } from '../types';
// CONTRACT:
// Backup settings contain provider credentials. They are stored as a v2 envelope:
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
// server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
// active admin public keys so settings can be repaired after restore/migration.
// Historical/imported databases may not have usable admin public keys; in that
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
//
// New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider
// secrets as plain config JSON.
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
const RUNTIME_INFO = 'runtime';
const PORTABLE_ALGORITHM = 'RSA-OAEP';
@@ -155,6 +167,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
}
}
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) return null;
return JSON.stringify({
version: 2,
portableOnly: true,
runtime: {
iv: '',
ciphertext: '',
},
portable: envelope.portable,
});
}
export async function encryptBackupSettingsEnvelope(
plaintext: string,
env: Env,
@@ -162,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
): Promise<string> {
const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
@@ -181,6 +204,7 @@ export async function encryptBackupSettingsEnvelope(
const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) {
try {
const publicKey = await importPortablePublicKey(user.publicKey!);
const wrappedKey = new Uint8Array(
await crypto.subtle.encrypt(
@@ -193,6 +217,9 @@ export async function encryptBackupSettingsEnvelope(
userId: user.id,
wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
}
const envelope: BackupSettingsEnvelopeV2 = {
+24 -5
View File
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
return true;
}
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
const hostname = endpoint.hostname.toLowerCase();
const bucketName = bucket.trim().toLowerCase();
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
}
function s3BucketBaseUrl(config: S3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
const bucket = config.bucket.trim();
if (config.addressingStyle === 'virtual-hosted-style') {
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
return endpoint;
}
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
}
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
}
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
@@ -501,7 +520,7 @@ async function putToS3(
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) {
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
throw new Error('Please select a backup file');
}
const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`);
@@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`);
@@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false;
if (!response.ok) {
+222
View File
@@ -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,331 @@
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: '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, 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;
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,
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, 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 ' +
'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.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
): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.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
): Promise<number> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
.bind(userId)
.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 = ?'
)
.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
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
.bind(userId, id)
.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 };
}
+121 -2
View File
@@ -1,5 +1,72 @@
import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db
.prepare(
@@ -77,8 +144,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+139
View File
@@ -0,0 +1,139 @@
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 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);
}
+4
View File
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
'favorite',
'reprompt',
'key',
'attachments',
'Attachments',
'attachments2',
'Attachments2',
'createdAt',
'created_at',
'creationDate',
+15
View File
@@ -233,6 +233,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
return Number(result.meta.changes ?? 0);
}
export async function updateTrustedTwoFactorTokensExpiryByDevice(
db: D1Database,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<number> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const result = await db
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
.bind(expiresAtMs, userId, deviceIdentifier, now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
+73
View File
@@ -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 -36
View File
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string
): Promise<RefreshTokenRecord | null> {
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token);
let row = await db
const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) {
const legacyRow = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
await saveRefreshTokenRecord(
token,
legacyRow.user_id,
legacyRow.expires_at,
legacyRow.device_identifier ?? null,
legacyRow.device_session_stamp ?? null
);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return {
userId: legacyRow.user_id,
expiresAt: legacyRow.expires_at,
deviceIdentifier: legacyRow.device_identifier ?? null,
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
};
}
}
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token);
+45 -3
View File
@@ -1,6 +1,14 @@
// IMPORTANT:
// Keep this schema list in sync with migrations/0001_init.sql.
// Any new table/column/index must be added to both places together.
// This is the runtime D1 schema bootstrap. Keep it in sync with
// migrations/0001_init.sql. Any new table/column/index must be added to both
// places together.
//
// WHEN CHANGING THIS:
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
// rerun these idempotent statements.
// - If the new table stores persistent data, update the backup export/import
// contract in src/services/backup-archive.ts and backup-import.ts.
// - Keep statements idempotent; D1 may execute them again on later requests.
const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
@@ -15,6 +23,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' +
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -69,10 +82,16 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
@@ -90,11 +109,34 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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 TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'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)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
+201 -4
View File
@@ -1,4 +1,4 @@
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 { ensureStorageSchema } from './storage-schema';
import {
@@ -18,12 +18,17 @@ import {
saveUser as saveStoredUser,
} from './storage-user-repo';
import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
@@ -96,7 +101,17 @@ import {
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
} from './storage-device-repo';
import {
createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
updateAuthRequestResponse as updateStoredAuthRequestResponse,
} from './storage-auth-request-repo';
import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
@@ -105,10 +120,31 @@ import {
getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate,
} from './storage-revision-repo';
import {
getUserDomainSettings as getStoredUserDomainSettings,
saveUserDomainSettings as saveStoredUserDomainSettings,
} from './storage-domain-rules-repo';
import {
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
} from './storage-account-passkey-repo';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-04-28';
// IMPORTANT:
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
// D1-backed storage.
// Contract:
@@ -139,6 +175,16 @@ export class StorageService {
return stmt.bind(...values.map(v => v === undefined ? null : v));
}
private async hasRequiredSchemaTables(): Promise<boolean> {
const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
const result = await this.db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
.bind(...REQUIRED_SCHEMA_TABLES)
.all<{ name: string }>();
const found = new Set((result.results || []).map((row) => row.name));
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
}
private sqlChunkSize(fixedBindCount: number): number {
return Math.max(
1,
@@ -182,7 +228,10 @@ export class StorageService {
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
? !(await this.hasRequiredSchemaTables())
: true;
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
}
@@ -270,6 +319,112 @@ export class StorageService {
await createStoredAuditLog(this.db, log);
}
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
// --- Domain rules ---
async getUserDomainSettings(userId: string) {
return getStoredUserDomainSettings(this.db, userId);
}
async saveUserDomainSettings(
userId: string,
equivalentDomains: string[][],
customEquivalentDomains: CustomEquivalentDomain[],
excludedGlobalEquivalentDomains: number[]
): Promise<void> {
await saveStoredUserDomainSettings(
this.db,
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains,
new Date().toISOString()
);
await this.updateRevisionDate(userId);
}
// --- Account passkeys / WebAuthn login credentials ---
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
}
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
}
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): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
}
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): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
}
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
await saveStoredAccountPasskeyChallenge(this.db, challenge);
}
async consumeAccountPasskeyChallenge(
challengeHash: string,
scope: AccountPasskeyChallengeScope,
userId: string | null,
nowMs: number = Date.now()
): Promise<AccountPasskeyChallenge | null> {
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
}
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {
@@ -432,7 +587,6 @@ export class StorageService {
this.db,
this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this),
this.saveRefreshToken.bind(this),
this.deleteRefreshToken.bind(this),
token
);
@@ -571,6 +725,45 @@ export class StorageService {
return deleteStoredDevicesByUserId(this.db, userId);
}
// --- Auth requests / Login with device ---
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
await createStoredAuthRequest(this.db, request);
}
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestById(this.db, id);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId);
}
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredPendingAuthRequestsByUserId(this.db, userId);
}
async updateAuthRequestResponse(
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
}
): Promise<boolean> {
return updateStoredAuthRequestResponse(this.db, id, userId, update);
}
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
return markStoredAuthRequestAuthenticated(this.db, id);
}
async pruneExpiredAuthRequests(): Promise<number> {
return pruneStoredExpiredAuthRequests(this.db);
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
return listStoredTrustedTokenSummaries(this.db, userId);
}
@@ -583,6 +776,10 @@ export class StorageService {
return deleteStoredTrustedTokensByUserId(this.db, userId);
}
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken(
+93
View File
@@ -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"
]
}
+3
View File
@@ -0,0 +1,3 @@
[
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
]
+100 -2
View File
@@ -2,6 +2,7 @@
export interface Env {
DB: D1Database;
NOTIFICATIONS_HUB: DurableObjectNamespace;
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
@@ -10,7 +11,9 @@ export interface Env {
// Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string;
TOTP_SECRET?: string;
WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string;
}
export type UserRole = 'admin' | 'user';
@@ -55,6 +58,34 @@ export interface User {
updatedAt: string;
}
export interface UserDomainSettings {
userId: string;
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
excludedGlobalEquivalentDomains: number[];
updatedAt: string | null;
}
export interface CustomEquivalentDomain {
id: string;
domains: string[];
excluded: boolean;
}
export interface GlobalEquivalentDomain {
type: number;
domains: string[];
excluded: boolean;
[key: string]: unknown;
}
export interface DomainRulesResponse {
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
globalEquivalentDomains: GlobalEquivalentDomain[];
object: 'domains';
}
export interface Invite {
code: string;
createdBy: string;
@@ -68,9 +99,13 @@ export interface Invite {
export interface AuditLog {
id: string;
actorUserId: string | null;
actorEmail?: string | null;
action: string;
category: 'auth' | 'security' | 'device' | 'data' | 'system';
level: 'info' | 'warn' | 'error' | 'security';
targetType: string | null;
targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null;
createdAt: string;
}
@@ -202,11 +237,64 @@ export interface Device {
updatedAt: string;
}
export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential {
id: string;
userId: string;
name: string;
publicKey: string;
credentialId: string;
counter: number;
type: string | null;
aaGuid: string | null;
transports: string[] | null;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
supportsPrf: boolean;
createdAt: string;
updatedAt: string;
}
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
export interface AccountPasskeyChallenge {
challengeHash: string;
scope: AccountPasskeyChallengeScope;
userId: string | null;
expiresAt: number;
usedAt: number | null;
createdAt: number;
}
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export type AuthRequestType = 0 | 1 | 2;
export interface AuthRequestRecord {
id: string;
userId: string;
organizationId: string | null;
type: AuthRequestType;
requestDeviceIdentifier: string;
requestDeviceType: number;
requestIpAddress: string | null;
requestCountryName: string | null;
responseDeviceIdentifier: string | null;
accessCode: string;
publicKey: string;
key: string | null;
masterPasswordHash: string | null;
approved: boolean | null;
creationDate: string;
responseDate: string | null;
authenticationDate: string | null;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
@@ -340,6 +428,14 @@ export interface MasterPasswordUnlock {
Object: string;
}
export interface WebAuthnPrfDecryptionOption {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
Object?: string;
}
export interface UserDecryptionOptions {
HasMasterPassword: boolean;
Object: string;
@@ -347,6 +443,7 @@ export interface UserDecryptionOptions {
MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null;
KeyConnectorOption: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
}
// API Response types
@@ -466,7 +563,8 @@ export interface SyncResponse {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
Object?: string;
} | null;
// PascalCase for desktop/browser clients
+269
View File
@@ -0,0 +1,269 @@
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
WebAuthnCredential,
} from '@simplewebauthn/server';
import type {
AccountPasskeyChallengeScope,
AccountPasskeyCredential,
AccountPasskeyPrfStatus,
Env,
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000;
const DEFAULT_RP_NAME = 'NodeWarden';
interface AccountPasskeyTokenPayload {
typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE;
scope: AccountPasskeyChallengeScope;
challenge: string;
userId: string | null;
rpId: string;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function sha256Base64Url(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', textBytes(value));
return bytesToBase64Url(new Uint8Array(digest));
}
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
}
export async function createAccountPasskeyToken(
env: Env,
input: {
scope: AccountPasskeyChallengeScope;
challenge: string;
userId?: string | null;
rpId: string;
ttlMs?: number;
}
): Promise<string> {
const now = Date.now();
const payload: AccountPasskeyTokenPayload = {
typ: ACCOUNT_PASSKEY_TOKEN_TYPE,
scope: input.scope,
challenge: input.challenge,
userId: input.userId ?? null,
rpId: input.rpId,
iat: now,
exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)),
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyAccountPasskeyToken(
env: Env,
token: string,
scope: AccountPasskeyChallengeScope
): Promise<AccountPasskeyTokenPayload | null> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return null;
const payload = decodeJson<AccountPasskeyTokenPayload>(parts[1]);
if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null;
if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null;
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } {
const url = new URL(request.url);
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) };
}
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return textBytes(userId);
}
export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null;
try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
return decoded.trim() || null;
} catch {
return null;
}
}
export function accountPasskeyPrfStatus(credential: Pick<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): AccountPasskeyPrfStatus {
if (!credential.supportsPrf) return 2;
if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0;
return 1;
}
export function buildWebAuthnPrfOption(
credential: AccountPasskeyCredential
): WebAuthnPrfDecryptionOption | null {
if (accountPasskeyPrfStatus(credential) !== 0) return null;
return {
EncryptedPrivateKey: credential.encryptedPrivateKey!,
EncryptedUserKey: credential.encryptedUserKey!,
CredentialId: credential.credentialId,
Transports: credential.transports || [],
Object: 'webAuthnPrfDecryptionOption',
};
}
export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record<string, unknown> {
const prfStatus = accountPasskeyPrfStatus(credential);
return {
Id: credential.id,
id: credential.id,
Name: credential.name,
name: credential.name,
PrfStatus: prfStatus,
prfStatus,
EncryptedPublicKey: credential.encryptedPublicKey,
encryptedPublicKey: credential.encryptedPublicKey,
EncryptedUserKey: credential.encryptedUserKey,
encryptedUserKey: credential.encryptedUserKey,
CreationDate: credential.createdAt,
RevisionDate: credential.updatedAt,
Object: 'webauthnCredential',
object: 'webauthnCredential',
};
}
export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential {
return {
id: credential.credentialId,
publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)),
counter: credential.counter,
transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined,
};
}
export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
attestationObject: String(response.attestationObject),
clientDataJSON: String(clientDataJSON),
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? String(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
},
};
}
export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
authenticatorData: String(response.authenticatorData),
clientDataJSON: String(clientDataJSON),
signature: String(response.signature),
userHandle: response.userHandle ? String(response.userHandle) : undefined,
},
};
}
export function normalizeAccountPasskeyName(value: unknown): string {
const normalized = String(value || '').trim();
return (normalized || 'Account passkey').slice(0, 128);
}
export function normalizeTransports(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const transports = value.map((item) => String(item || '').trim()).filter(Boolean);
return transports.length ? transports.slice(0, 12) : null;
}
export function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
+6 -4
View File
@@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean {
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
if (!origin) {
return { allowOrigin: null, allowCredentials: false };
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
@@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
return { allowOrigin: null, allowCredentials: false };
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { User, UserDecryptionOptions } from '../types';
import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return '';
@@ -40,7 +40,8 @@ export function buildMasterPasswordUnlock(
}
export function buildUserDecryptionOptions(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
): UserDecryptionOptions {
return {
HasMasterPassword: true,
@@ -48,6 +49,7 @@ export function buildUserDecryptionOptions(
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOption,
};
}
+6
View File
@@ -18,6 +18,12 @@
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>NodeWarden</title>
<style>
+48
View File
@@ -0,0 +1,48 @@
{
"name": "NodeWarden",
"short_name": "NodeWarden",
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
"id": "/",
"start_url": "/vault",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#eef4ff",
"theme_color": "#0f172a",
"categories": ["security", "productivity", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Vault",
"short_name": "Vault",
"url": "/vault",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "TOTP Codes",
"short_name": "TOTP",
"url": "/vault/totp",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+428
View File
@@ -0,0 +1,428 @@
Attribution-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More_considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-ShareAlike 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-ShareAlike 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
+4
View File
@@ -0,0 +1,4 @@
Payment logos in this directory are from datatrans/payment-logos.
Source: https://github.com/datatrans/payment-logos
License: CC-BY-SA-4.0
@@ -0,0 +1,4 @@
<svg width="120" height="80" version="1.1" viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg">
<rect x="40" width="80" height="80" rx="4" fill="#fff" fill-rule="evenodd" />
<path d="m120 76v-8.6763h-9.651l-4.969-5.4944-4.994 5.4944h-31.822v-25.607h-10.27l12.74-28.831h12.286l4.3857 9.877v-9.877h15.208l2.64 7.4429 2.658-7.4429h11.789v-8.8854c0-2.2091-1.7909-4-4-4h-112c-2.2091 4.4409e-16 -4 1.7909-4 4v72c4.4409e-16 2.2091 1.7909 4 4 4h112c2.2091 0 4-1.7909 4-4zm-8.026-11.882h8.026l-10.616-11.258 10.616-11.13h-7.898l-6.556 7.1645-6.4935-7.1645h-8.0275l10.554 11.194-10.554 11.194h7.8041l6.5889-7.2283 6.556 7.2283zm1.878-11.249 6.148 6.5406v-13.027l-6.148 6.4861zm-35.78 6.0675v-3.4864h12.633v-5.0534h-12.633v-3.4859h12.953l5e-4 -5.1815h-19.062v22.388h19.062l-5e-4 -5.1813h-12.953zm35.883-20.456h6.045v-22.388h-9.403l-5.022 13.944-4.989-13.944h-9.5631v22.388h6.0446v-15.672l5.7575 15.672h5.373l5.757-15.704v15.704zm-29.809 0h6.8765l-9.8824-22.388h-7.8682l-9.8833 22.388h6.7166l1.8554-4.4776h10.298l1.887 4.4776zm-3.9976-9.4992h-6.0773l3.0387-7.3242 3.0386 7.3242z" fill="#0690FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,11 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="url(#paint0_linear_804_2)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.3997 64.8343C79.0213 64.8992 91.4542 53.7631 91.4542 40.2157C91.4542 25.4007 79.0213 15.1605 65.3997 15.1654H53.6768C39.8921 15.1605 28.5459 25.4038 28.5459 40.2157C28.5459 53.7661 39.8921 64.8993 53.6768 64.8343H65.3997Z" fill="#3477B9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.6852 17.1522C41.0891 17.1561 30.8821 27.3313 30.8792 39.8896C30.8821 52.4456 41.089 62.6199 53.6852 62.6238C66.2843 62.6199 76.4934 52.4456 76.4952 39.8896C76.4933 27.3313 66.2843 17.1561 53.6852 17.1522ZM39.2291 39.8896C39.241 33.7529 43.0866 28.5199 48.5095 26.4404V53.3355C43.0866 51.2572 39.2409 46.0271 39.2291 39.8896ZM58.859 53.3415V26.4396C64.2838 28.514 68.1355 33.7499 68.1453 39.8896C68.1355 46.0311 64.2838 51.263 58.859 53.3415Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_804_2" x1="1.68141e-06" y1="21" x2="120" y2="54" gradientUnits="userSpaceOnUse">
<stop stop-color="#3479C0"/>
<stop offset="1" stop-color="#133362"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,22 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29 80H116.002C118.21 80 120 78.211 120 75.9957V48C120 48 87.8616 70.1063 29 80Z" fill="#E7792B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M113.088 33.8624C113.088 30.7125 110.888 28.8951 107.053 28.8951H102.12V45.7197H105.443V38.9609H105.877L110.481 45.7197H114.571L109.202 38.6314C111.708 38.129 113.088 36.4383 113.088 33.8624ZM106.414 36.6411H105.443V31.5451H106.467C108.538 31.5451 109.665 32.4018 109.665 34.0385C109.665 35.7305 108.538 36.6411 106.414 36.6411Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M90.4839 45.7197H99.9176V42.8713H93.8077V38.3298H99.6923V35.4802H93.8077V31.746H99.9176V28.8951H90.4839V45.7197Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M80.7677 40.1959L76.2205 28.8951H72.5864L79.8236 46.1512H81.613L88.9799 28.8951H85.3742L80.7677 40.1959Z" fill="#1A1918"/>
<path d="M64.6178 46.7197C69.7118 46.7197 73.8414 42.6454 73.8414 37.6197C73.8414 32.5939 69.7118 28.5197 64.6178 28.5197C59.5238 28.5197 55.3943 32.5939 55.3943 37.6197C55.3943 42.6454 59.5238 46.7197 64.6178 46.7197Z" fill="url(#paint0_radial_823_341)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.2231 37.3191C41.2231 42.2643 45.159 46.0986 50.224 46.0986C51.6556 46.0986 52.8817 45.8211 54.3943 45.1184V41.2555C53.0642 42.5685 51.8869 43.0982 50.3788 43.0982C47.0287 43.0982 44.651 40.7017 44.651 37.2944C44.651 34.0645 47.1038 31.5165 50.224 31.5165C51.8104 31.5165 53.0115 32.0749 54.3943 33.4093V29.5483C52.9344 28.8177 51.7334 28.5148 50.3024 28.5148C45.2631 28.5148 41.2231 32.4272 41.2231 37.3191Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2687 35.3515C33.2725 34.6229 32.6868 34.1419 32.6868 33.2332C32.6868 32.173 33.731 31.3683 35.1646 31.3683C36.1614 31.3683 36.9803 31.772 37.8467 32.7307L39.5873 30.4824C38.157 29.248 36.446 28.6169 34.5763 28.6169C31.5589 28.6169 29.2576 30.6839 29.2576 33.4379C29.2576 35.7558 30.3295 36.9421 33.453 38.0516C34.7555 38.5047 35.4182 38.8063 35.7529 39.0097C36.417 39.4381 36.7497 40.0439 36.7497 40.7504C36.7497 42.1135 35.6515 43.1236 34.1671 43.1236C32.5807 43.1236 31.3032 42.341 30.537 40.8798L28.3879 42.9214C29.9204 45.1405 31.7611 46.124 34.2923 46.124C37.7485 46.124 40.1736 43.8568 40.1736 40.5996C40.1736 37.9268 39.0523 36.7165 35.2687 35.3515Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8091 28.8951H27.1355V45.7197H23.8091V28.8951Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1242 28.8951H8.2417V45.7197H13.0985C15.6811 45.7197 17.5456 45.1184 19.1828 43.7775C21.1283 42.1889 22.2786 39.7949 22.2786 37.319C22.2786 32.3537 18.5187 28.8951 13.1242 28.8951ZM17.01 41.5336C15.9644 42.4651 14.6073 42.8713 12.4582 42.8713H11.5655V31.746H12.4582C14.6073 31.746 15.9111 32.1249 17.01 33.1064C18.1603 34.1171 18.8521 35.683 18.8521 37.2943C18.8521 38.9096 18.1603 40.5235 17.01 41.5336Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115.21 29.5275C115.21 29.233 115.005 29.0712 114.643 29.0712H114.162V30.5499H114.52V29.9766L114.939 30.5499H115.376L114.883 29.9402C115.094 29.8843 115.21 29.7329 115.21 29.5275ZM114.58 29.7296H114.52V29.3429H114.584C114.761 29.3429 114.853 29.4059 114.853 29.5327C114.853 29.664 114.76 29.7296 114.58 29.7296Z" fill="#1A1918"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M114.715 28.5187C113.987 28.5187 113.41 29.092 113.41 29.8077C113.41 30.5233 113.994 31.0973 114.715 31.0973C115.424 31.0973 116.005 30.5175 116.005 29.8077C116.005 29.1018 115.424 28.5187 114.715 28.5187ZM114.71 30.8672C114.138 30.8672 113.669 30.3966 113.669 29.8096C113.669 29.2207 114.132 28.7508 114.71 28.7508C115.28 28.7508 115.745 29.2318 115.745 29.8096C115.745 30.3914 115.28 30.8672 114.71 30.8672Z" fill="#1A1918"/>
<defs>
<radialGradient id="paint0_radial_823_341" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(71.5 44) rotate(-142.431) scale(16.4012 16.1816)">
<stop stop-color="#F59900"/>
<stop offset="0.210082" stop-color="#F39501"/>
<stop offset="0.908163" stop-color="#CE3C0B"/>
<stop offset="1" stop-color="#A4420A"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path d="M100.9 58.8C100.9 65.8 95.1996 71.5 88.1996 71.5H19.0996V21.2C19.0996 14.2 24.7996 8.5 31.7996 8.5H100.9V58.8Z" fill="white"/>
<path d="M78.3994 45.9H83.6494C83.7994 45.9 84.1494 45.85 84.2994 45.85C85.2994 45.65 86.1494 44.75 86.1494 43.5C86.1494 42.3 85.2994 41.4 84.2994 41.15C84.1494 41.1 83.8494 41.1 83.6494 41.1H78.3994V45.9Z" fill="url(#paint0_linear_833_6149)"/>
<path d="M83.0494 12.75C78.0494 12.75 73.9494 16.8 73.9494 21.85V31.3H86.7994C87.0994 31.3 87.4494 31.3 87.6994 31.35C90.5994 31.5 92.7494 33 92.7494 35.6C92.7494 37.65 91.2994 39.4 88.5994 39.75V39.85C91.5494 40.05 93.7994 41.7 93.7994 44.25C93.7994 47 91.2994 48.8 87.9994 48.8H73.8994V67.3H87.2494C92.2494 67.3 96.3494 63.25 96.3494 58.2V12.75H83.0494Z" fill="url(#paint1_linear_833_6149)"/>
<path d="M85.4994 36.2C85.4994 35 84.6494 34.2 83.6494 34.05C83.5494 34.05 83.2994 34 83.1494 34H78.3994V38.4H83.1494C83.2994 38.4 83.5994 38.4 83.6494 38.35C84.6494 38.2 85.4994 37.4 85.4994 36.2Z" fill="url(#paint2_linear_833_6149)"/>
<path d="M57.8988 12.75C52.8988 12.75 48.7988 16.8 48.7988 21.85V33.75C51.0988 31.8 55.0988 30.55 61.5488 30.85C64.9988 31 68.6988 31.95 68.6988 31.95V35.8C66.8488 34.85 64.6488 34 61.7988 33.8C56.8988 33.45 53.9488 35.85 53.9488 40.05C53.9488 44.3 56.8988 46.7 61.7988 46.3C64.6488 46.1 66.8488 45.2 68.6988 44.3V48.15C68.6988 48.15 65.0488 49.1 61.5488 49.25C55.0988 49.55 51.0988 48.3 48.7988 46.35V67.35H62.1488C67.1488 67.35 71.2488 63.3 71.2488 58.25V12.75H57.8988Z" fill="url(#paint3_linear_833_6149)"/>
<path d="M32.7496 12.75C27.7496 12.75 23.6496 16.8 23.6496 21.85V44.3C26.1996 45.55 28.8496 46.35 31.4996 46.35C34.6496 46.35 36.3496 44.45 36.3496 41.85V31.25H44.1496V41.8C44.1496 45.9 41.5996 49.25 32.9496 49.25C27.6996 49.25 23.5996 48.1 23.5996 48.1V67.25H36.9496C41.9496 67.25 46.0496 63.2 46.0496 58.15V12.75H32.7496Z" fill="url(#paint4_linear_833_6149)"/>
<defs>
<linearGradient id="paint0_linear_833_6149" x1="60.9804" y1="40.0821" x2="126.075" y2="40.0821" gradientUnits="userSpaceOnUse">
<stop stop-color="#007940"/>
<stop offset="0.2285" stop-color="#00873F"/>
<stop offset="0.7433" stop-color="#40A737"/>
<stop offset="1" stop-color="#5CB531"/>
</linearGradient>
<linearGradient id="paint1_linear_833_6149" x1="73.9404" y1="40.0023" x2="96.4108" y2="40.0023" gradientUnits="userSpaceOnUse">
<stop stop-color="#007940"/>
<stop offset="0.2285" stop-color="#00873F"/>
<stop offset="0.7433" stop-color="#40A737"/>
<stop offset="1" stop-color="#5CB531"/>
</linearGradient>
<linearGradient id="paint2_linear_833_6149" x1="73.9396" y1="36.1925" x2="96.409" y2="36.1925" gradientUnits="userSpaceOnUse">
<stop stop-color="#007940"/>
<stop offset="0.2285" stop-color="#00873F"/>
<stop offset="0.7433" stop-color="#40A737"/>
<stop offset="1" stop-color="#5CB531"/>
</linearGradient>
<linearGradient id="paint3_linear_833_6149" x1="48.6689" y1="40.0023" x2="70.8287" y2="40.0023" gradientUnits="userSpaceOnUse">
<stop stop-color="#6C2C2F"/>
<stop offset="0.1735" stop-color="#882730"/>
<stop offset="0.5731" stop-color="#BE1833"/>
<stop offset="0.8585" stop-color="#DC0436"/>
<stop offset="1" stop-color="#E60039"/>
</linearGradient>
<linearGradient id="paint4_linear_833_6149" x1="23.6382" y1="40.0023" x2="46.4553" y2="40.0023" gradientUnits="userSpaceOnUse">
<stop stop-color="#1F286F"/>
<stop offset="0.4751" stop-color="#004E94"/>
<stop offset="0.8261" stop-color="#0066B1"/>
<stop offset="1" stop-color="#006FBC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,7 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#00A2E5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#7375CF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#00A2E5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,7 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.5288 54.6562V53.7384H97.289L97.0137 54.3698L96.7378 53.7384H96.498V54.6562H96.6675V53.9637L96.9257 54.5609H97.1011L97.36 53.9624V54.6562H97.5288ZM96.0111 54.6562V53.8947H96.318V53.7397H95.5361V53.8947H95.843V54.6562H96.0111Z" fill="#F79E1B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6521 58.595H70.3479V21.4044H49.6521V58.595Z" fill="#FF5F00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.2675 40.0003C98.2675 53.063 87.6791 63.652 74.6171 63.652C69.0996 63.652 64.0229 61.7624 60 58.5956C65.5011 54.2646 69.0339 47.5448 69.0339 40.0003C69.0339 32.4552 65.5011 25.7354 60 21.4044C64.0229 18.2376 69.0996 16.348 74.6171 16.348C87.6791 16.348 98.2675 26.937 98.2675 40.0003Z" fill="#F79E1B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.966 40.0003C50.966 32.4552 54.4988 25.7354 59.9999 21.4044C55.977 18.2376 50.9003 16.348 45.3828 16.348C32.3208 16.348 21.7324 26.937 21.7324 40.0003C21.7324 53.063 32.3208 63.652 45.3828 63.652C50.9003 63.652 55.977 61.7624 59.9999 58.5956C54.4988 54.2646 50.966 47.5448 50.966 40.0003Z" fill="#EB001B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,16 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.5282 14.1788C73.7421 14.2654 70.3351 16.4596 69.7146 19.1653L60.2981 60.8371C59.6776 63.568 61.3656 65.7903 64.0813 65.8312H84.9996C87.6739 65.6989 90.2725 63.5298 90.8824 60.8549L100.299 19.1828C100.93 16.424 99.201 14.1839 96.4402 14.1839L76.5282 14.1788Z" fill="#01798A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M60.2982 60.8371L69.7148 19.1653C70.3353 16.4596 73.7422 14.2654 76.4776 14.1815L68.5607 14.1764L54.2967 14.1737C51.5536 14.2298 48.1023 16.4394 47.482 19.1653L38.0627 60.8371C37.4399 63.568 39.1304 65.7903 41.8443 65.8312H64.0814C61.3657 65.7903 59.6777 63.568 60.2982 60.8371Z" fill="#024381"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.0627 60.8371L47.482 19.1653C48.1023 16.4394 51.5536 14.2298 54.2967 14.1737L36.0237 14.1689C33.2653 14.1689 29.7287 16.4039 29.0983 19.1653L19.6789 60.8371C19.6216 61.0914 19.5898 61.3406 19.5708 61.5845V62.3576C19.7552 64.3483 21.2754 65.798 23.4605 65.8312H41.8443C39.1304 65.7903 37.4399 63.568 38.0627 60.8371Z" fill="#DD0228"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6818 44.5384H55.0276C55.3454 44.5384 55.5592 44.4318 55.6594 44.2206L56.558 42.8756H58.9644L58.4626 43.7603H61.3479L60.9819 45.1157H57.5486C57.1532 45.7107 56.6665 45.9904 56.0812 45.9572H54.2929L54.6818 44.5384ZM54.2867 46.4811H60.608L60.2051 47.9535H57.6629L57.275 49.3747H59.7488L59.3458 50.8469H56.872L56.2974 52.947C56.1551 53.298 56.3422 53.4559 56.8556 53.4201H58.8717L58.4982 54.7882H54.6274C53.8937 54.7882 53.642 54.3685 53.8722 53.527L54.6069 50.8469H53.0256L53.4273 49.3747H55.0088L55.3964 47.9535H53.8848L54.2867 46.4811ZM64.3762 42.8656L64.2766 43.7275C64.2766 43.7275 65.4691 42.8322 66.552 42.8322H70.5538L69.0234 48.3727C68.8965 49.0061 68.3523 49.3211 67.3911 49.3211H62.8554L61.7929 53.2116C61.7317 53.4201 61.8182 53.527 62.0471 53.527H62.9395L62.6115 54.7346H60.3426C59.4717 54.7346 59.1095 54.4727 59.2531 53.9466L62.2554 42.8656H64.3762ZM67.765 44.4318H64.1932L63.7659 45.9268C63.7659 45.9268 64.3608 45.4973 65.3548 45.4819C66.3461 45.4664 67.4776 45.4819 67.4776 45.4819L67.765 44.4318ZM66.471 47.8999C66.735 47.9357 66.8828 47.8312 66.9006 47.5845L67.1192 46.7964H63.5419L63.2419 47.8999H66.471ZM64.0581 49.6899H66.12L66.0817 50.5823H66.6307C66.9081 50.5823 67.0456 50.4935 67.0456 50.3181L67.2081 49.7408H68.9218L68.693 50.5823C68.4994 51.2842 67.9863 51.6503 67.1523 51.6861H66.054L66.0488 53.2116C66.0287 53.4559 66.2496 53.5804 66.7046 53.5804H67.7369L67.4037 54.7882H64.9276C64.2335 54.8212 63.8932 54.4905 63.9004 53.7889L64.0581 49.6899Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.8218 44.5995L73.3 42.9164H75.7177L75.6133 43.534C75.6133 43.534 76.8488 42.9164 77.7385 42.9164H80.7282L80.253 44.5995H79.7827L77.5275 52.5378H77.9978L77.5504 54.1143H77.0801L76.8844 54.7983H74.543L74.7383 54.1143H70.1191L70.5693 52.5378H71.0321L73.2894 44.5995H72.8218ZM75.4303 44.5995L74.815 46.7479C74.815 46.7479 75.8678 46.3439 76.7753 46.2295C76.9758 45.4792 77.2378 44.5995 77.2378 44.5995H75.4303ZM74.53 47.755L73.9126 50.0053C73.9126 50.0053 75.0794 49.4307 75.8801 49.3823C76.1114 48.5126 76.3429 47.755 76.3429 47.755H74.53ZM74.9826 52.5378L75.4454 50.9055H73.6407L73.1755 52.5378H74.9826ZM80.8301 42.8122H83.1031L83.1995 43.651C83.1845 43.8645 83.3114 43.9665 83.5809 43.9665H83.9825L83.5762 45.3877H81.9055C81.2676 45.4207 80.9395 45.1768 80.9091 44.6503L80.8301 42.8122ZM87.5266 45.8608L87.0946 47.3865H84.7504L84.3485 48.805H86.6903L86.2555 50.3282H83.6473L83.0572 51.2209H84.3338L84.6287 53.0082C84.6639 53.1862 84.8216 53.2727 85.0911 53.2727H85.4876L85.071 54.7447H83.6675C82.9403 54.7805 82.5643 54.5363 82.5336 54.0101L82.1953 52.3777L81.0336 54.1143C80.7589 54.605 80.3368 54.834 79.7677 54.7983H77.6243L78.0413 53.3259H78.71C78.9847 53.3259 79.2132 53.2039 79.4191 52.9573L81.2371 50.3282H78.893L79.3274 48.805H81.87L82.2743 47.3865H79.7293L80.1641 45.8608H87.5266Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M89.8554 40.9179C89.0926 42.5399 88.3657 43.4857 87.9388 43.9256C87.5113 44.3606 86.665 45.3726 84.626 45.2962L84.8015 44.058C86.5172 43.5291 87.4452 41.1464 87.9741 40.0913L87.3437 32.3209L88.6708 32.3031H89.7843L89.904 37.1775L91.9909 32.3031H94.1038L89.8554 40.9179Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.9472 32.8904L83.1078 33.4677C82.2308 32.7813 81.43 32.3566 79.8846 33.0735C77.7792 34.0499 76.02 41.5382 81.8165 39.0717L82.147 39.4633L84.4275 39.5218L85.925 32.7175L83.9472 32.8904ZM82.6505 36.6104C82.2841 37.6911 81.4659 38.4055 80.8252 38.2022C80.1846 38.0038 79.9557 36.9612 80.3269 35.8781C80.6929 34.7949 81.5165 34.083 82.1521 34.2864C82.7928 34.4847 83.024 35.5272 82.6505 36.6104Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.7625 27.8254H75.9591C76.9583 27.8254 77.731 28.0519 78.2622 28.4966C78.7911 28.9468 79.0558 29.5927 79.0558 30.4343V30.4596C79.0558 30.6197 79.0452 30.8003 79.0302 30.996C79.0045 31.1893 78.9713 31.385 78.9286 31.5885C78.6997 32.7023 78.1682 33.5973 77.3472 34.2762C76.5232 34.9525 75.5473 35.2933 74.4236 35.2933H71.6369L70.7752 39.5218H68.3623L70.7625 27.8254ZM72.0613 33.2592H74.3727C74.975 33.2592 75.4529 33.1192 75.8014 32.8422C76.1473 32.5625 76.3761 32.1354 76.503 31.5557C76.5232 31.4486 76.5358 31.3521 76.5512 31.2632C76.5591 31.1794 76.569 31.0952 76.569 31.0141C76.569 30.5995 76.4219 30.2995 76.1267 30.1113C75.8319 29.9204 75.3694 29.8291 74.7284 29.8291H72.7657L72.0613 33.2592Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M61.9146 32.3006H63.7046L63.5015 33.3431L63.7553 33.0456C64.3354 32.4253 65.0421 32.1175 65.8709 32.1175C66.6235 32.1175 67.1673 32.3363 67.5053 32.776C67.8388 33.216 67.9353 33.8237 67.7724 34.6043L66.7911 39.5218H64.9504L65.8405 35.0646C65.9318 34.6043 65.9065 34.261 65.7666 34.0397C65.6216 33.8185 65.3595 33.7093 64.9761 33.7093C64.503 33.7093 64.1066 33.8566 63.7785 34.1492C63.4529 34.4441 63.237 34.8535 63.1355 35.3747L62.3118 39.5218H60.4722L61.9146 32.3006Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M53.4452 38.97C52.9265 38.4742 52.6646 37.8055 52.6621 36.9563C52.6621 36.8112 52.6709 36.6461 52.6902 36.4654C52.7093 36.2823 52.7335 36.1043 52.7677 35.9392C53.0028 34.767 53.5038 33.8364 54.2753 33.1498C55.0456 32.4608 55.975 32.1149 57.0631 32.1149C57.9541 32.1149 58.6608 32.3642 59.1784 32.8625C59.6956 33.3635 59.9548 34.0397 59.9548 34.8994C59.9548 35.0466 59.9436 35.217 59.9244 35.4001C59.9015 35.5857 59.8738 35.7637 59.8416 35.9392C59.6118 37.0935 59.1124 38.014 58.3407 38.6879C57.569 39.3668 56.6423 39.7047 55.5618 39.7047C54.6669 39.7047 53.9626 39.4607 53.4452 38.97ZM57.2245 37.541C57.5741 37.1622 57.8245 36.5874 57.9771 35.8222C58 35.7028 58.0202 35.5781 58.0328 35.4535C58.0455 35.3314 58.0506 35.217 58.0506 35.1128C58.0506 34.6678 57.9374 34.3221 57.7099 34.0779C57.4838 33.8312 57.1623 33.7093 56.7467 33.7093C56.1973 33.7093 55.7499 33.9023 55.3993 34.2889C55.0456 34.6755 54.7952 35.2603 54.6375 36.0383C54.616 36.1578 54.5982 36.2774 54.5818 36.3943C54.5691 36.5138 54.5653 36.6257 54.5677 36.7273C54.5677 37.1698 54.681 37.5106 54.9084 37.752C55.1345 37.9937 55.4547 38.1131 55.8758 38.1131C56.4275 38.1131 56.8749 37.9224 57.2245 37.541Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7065 32.3031H51.6897L50.1362 39.5193H48.157L49.7065 32.3031ZM50.3308 29.6741H52.3316L51.9579 31.4257H49.9572L50.3308 29.6741Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.4044 32.3006H43.1929L42.9884 33.3431L43.245 33.0456C43.8248 32.4253 44.529 32.1175 45.3603 32.1175C46.1129 32.1175 46.6556 32.3363 46.9964 32.776C47.3318 33.216 47.4234 33.8237 47.2647 34.6043L46.2794 39.5218H44.4413L45.3311 35.0646C45.4227 34.6043 45.3974 34.261 45.256 34.0397C45.1164 33.8185 44.8492 33.7093 44.4641 33.7093C43.9913 33.7093 43.5934 33.8566 43.2692 34.1492C42.9437 34.4441 42.729 34.8535 42.6235 35.3747L41.8036 39.5218H39.9617L41.4044 32.3006Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.0721 35.484C38.7924 36.8546 38.1441 37.9072 37.1385 38.6548C36.142 39.3896 34.8567 39.7582 33.2831 39.7582C31.8022 39.7582 30.7165 39.3819 30.0237 38.6267C29.5432 38.0902 29.3042 37.4087 29.3042 36.5849C29.3042 36.2443 29.345 35.8781 29.4263 35.484L31.103 27.3984H33.6352L31.9814 35.3925C31.9305 35.6138 31.9102 35.8197 31.9128 36.0053C31.9102 36.4148 32.0118 36.7503 32.2177 37.0121C32.5177 37.4013 33.0046 37.5944 33.6822 37.5944C34.4613 37.5944 35.1033 37.4038 35.6016 37.0197C36.1 36.6384 36.4254 36.0969 36.5715 35.3925L38.2305 27.3984H40.7499L39.0721 35.484Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,7 @@
<svg width="120" height="80" viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="80" rx="4" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.6666 44.9375L90.3239 35.0625L92.3809 44.9375H86.6666ZM100.952 52.8375L95.8086 27.1625H88.7383C86.3525 27.1625 85.7723 29.0759 85.7723 29.0759L76.1904 52.8375H82.8868L84.2269 49.0244H92.3947L93.1479 52.8375H100.952Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M77.1866 33.5711L78.0952 28.244C78.0952 28.244 75.2896 27.1625 72.3648 27.1625C69.2031 27.1625 61.6955 28.5638 61.6955 35.3738C61.6955 41.7825 70.5071 41.8621 70.5071 45.2266C70.5071 48.5912 62.6034 47.9901 59.9955 45.8676L59.0476 51.4362C59.0476 51.4362 61.8919 52.8375 66.2397 52.8375C70.5869 52.8375 77.1467 50.5544 77.1467 44.3455C77.1467 37.8964 68.2552 37.296 68.2552 34.4921C68.2552 31.6882 74.4602 32.0484 77.1866 33.5711Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6517 52.8375H47.6191L52.0144 27.1625H59.0477L54.6517 52.8375Z" fill="#1434CB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.3113 27.1625L35.9217 44.8213L35.1663 41.0185L35.167 41.0199L32.9114 29.4749C32.9114 29.4749 32.6394 27.1625 29.7324 27.1625H19.1709L19.0476 27.5966C19.0476 27.5966 22.2782 28.2669 26.057 30.5326L31.8793 52.8375H38.8617L49.5238 27.1625H42.3113Z" fill="#1434CB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+322 -14
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'wouter';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
import AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
import AuthViews from '@/components/AuthViews';
import NotFoundPage from '@/components/NotFoundPage';
import PublicSendPage from '@/components/PublicSendPage';
@@ -22,9 +23,17 @@ import {
saveSession,
stripProfileSecrets,
} from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import {
encryptSessionUserKeyForAuthRequest,
isPendingAuthRequest,
listPendingAuthRequests,
respondToAuthRequest,
} from '@/lib/api/auth-requests';
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
import { getSends } from '@/lib/api/send';
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import {
parseSignalRTextFrames,
@@ -35,13 +44,16 @@ import {
bootstrapAppSession,
type CompletedLogin,
readInitialAppBootstrapState,
completePasskeyPasswordLogin,
performPasswordLogin,
performPasskeyLogin,
performRecoverTwoFactorLogin,
performRegistration,
performTotpLogin,
hydrateLockedSession,
performUnlock,
type JwtUnsafeReason,
type PendingPasskeyPassword,
type PendingTotp,
} from '@/lib/app-auth';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
@@ -52,6 +64,7 @@ import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import {
@@ -68,7 +81,7 @@ import {
createDemoMainRoutesProps,
} from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -87,6 +100,9 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
const APP_ROUTE_PATHS = [
'/',
@@ -94,10 +110,13 @@ const APP_ROUTE_PATHS = [
'/vault/totp',
'/sends',
'/admin',
'/security/devices',
'/logs',
LEGACY_DEVICE_MANAGEMENT_ROUTE,
DEVICE_MANAGEMENT_ROUTE,
'/backup',
'/settings',
SETTINGS_ACCOUNT_ROUTE,
SETTINGS_DOMAIN_RULES_ROUTE,
'/help',
...IMPORT_ROUTE_PATHS,
] as const;
@@ -141,7 +160,9 @@ function resolveSystemTheme(): 'light' | 'dark' {
function readLockTimeoutMinutes(): LockTimeoutMinutes {
if (typeof window === 'undefined') return 15;
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY));
const stored = window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY);
if (stored === null || stored.trim() === '') return 15;
const value = Number(stored);
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
}
@@ -162,12 +183,13 @@ export default function App() {
[initialBootstrap]
);
const queryClient = useQueryClient();
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'passkey' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
const [registrationInviteRequired, setRegistrationInviteRequired] = useState(initialBootstrap.registrationInviteRequired);
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
const [loginValues, setLoginValues] = useState({ email: '', password: '' });
@@ -192,6 +214,8 @@ export default function App() {
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
const [pendingPasskeyPassword, setPendingPasskeyPassword] = useState<PendingPasskeyPassword | null>(null);
const [passkeyPassword, setPasskeyPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false);
@@ -199,6 +223,8 @@ export default function App() {
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
@@ -224,9 +250,13 @@ export default function App() {
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>('');
const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
const notificationRefreshTimerRef = useRef<number | null>(null);
const domainRulesSaveSeqRef = useRef(0);
const loginEmailRef = useRef(loginValues.email);
const loginHintRequestSeqRef = useRef(0);
const { toasts, pushToast, removeToast } = useToastManager();
useEffect(() => {
@@ -259,6 +289,7 @@ export default function App() {
}, [inviteCodeFromUrl]);
useEffect(() => {
loginEmailRef.current = loginValues.email;
const normalizedEmail = loginValues.email.trim().toLowerCase();
setLoginHintState((prev) => (
prev.email && prev.email !== normalizedEmail
@@ -406,6 +437,7 @@ export default function App() {
const normalizedCurrentHashPath = currentHashPath.replace(/^\/+/, '').replace(/\/+$/, '');
const isDemoPublicSendRoute = /^send\/[^/]+(?:\/[^/]+)?$/i.test(normalizedCurrentHashPath);
setDefaultKdfIterations(initialBootstrap.defaultKdfIterations);
setRegistrationInviteRequired(initialBootstrap.registrationInviteRequired);
setJwtWarning(null);
setSession(null);
setProfile(null);
@@ -419,7 +451,9 @@ export default function App() {
(async () => {
const boot = await bootstrapAppSession(initialBootstrap);
if (!mounted) return;
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
setDefaultKdfIterations(boot.defaultKdfIterations);
setRegistrationInviteRequired(boot.registrationInviteRequired);
setJwtWarning(boot.jwtWarning);
setSession(boot.session);
setProfile(boot.profile);
@@ -463,7 +497,9 @@ export default function App() {
setUnlockPreparing(false);
setPendingTotp(null);
setPendingTotpMode(null);
setPendingPasskeyPassword(null);
setTotpCode('');
setPasskeyPassword('');
setUnlockPassword('');
setPhase('app');
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
@@ -518,6 +554,78 @@ export default function App() {
}
}
async function handlePasskeyLogin() {
if (pendingAuthAction) return;
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
return;
}
setPendingAuthAction('passkey');
try {
const result = await performPasskeyLogin(defaultKdfIterations);
if (result.kind === 'success') {
await finalizeLogin(result.login);
return;
}
if (result.kind === 'password') {
setPendingPasskeyPassword(result.pendingPasskeyPassword);
setLoginValues({ email: result.pendingPasskeyPassword.email, password: '' });
setPasskeyPassword('');
pushToast('warning', t('txt_passkey_requires_master_password'));
return;
}
pushToast('error', result.message || t('txt_login_failed'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
} finally {
setPendingAuthAction(null);
}
}
async function handlePasskeyUnlock() {
if (pendingAuthAction) return;
const expectedEmail = (profile?.email || session?.email || '').trim().toLowerCase();
if (!expectedEmail) return;
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
return;
}
setPendingAuthAction('passkey');
try {
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked'));
return;
}
if (result.kind === 'password') {
pushToast('error', t('txt_account_passkey_direct_unlock_unavailable_error'));
return;
}
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
} finally {
setPendingAuthAction(null);
}
}
async function handlePasskeyPasswordLogin() {
if (pendingAuthAction || !pendingPasskeyPassword) return;
if (!passkeyPassword) {
pushToast('error', t('txt_please_input_master_password'));
return;
}
setPendingAuthAction('login');
try {
const login = await completePasskeyPasswordLogin(pendingPasskeyPassword, passkeyPassword);
await finalizeLogin(login);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
} finally {
setPendingAuthAction(null);
}
}
async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return;
@@ -630,6 +738,7 @@ export default function App() {
return;
}
const requestSeq = ++loginHintRequestSeqRef.current;
setLoginHintState({
email,
loading: true,
@@ -638,6 +747,7 @@ export default function App() {
try {
const result = await getPasswordHint(email);
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
openPasswordHintDialog(result.masterPasswordHint);
setLoginHintState({
email,
@@ -645,6 +755,7 @@ export default function App() {
hint: result.masterPasswordHint,
});
} catch (error) {
if (loginHintRequestSeqRef.current !== requestSeq || loginEmailRef.current.trim().toLowerCase() !== email) return;
setLoginHintState({
email: '',
loading: false,
@@ -728,6 +839,7 @@ export default function App() {
setConfirm(null);
setSession(null);
clearProfileSnapshot();
clearOfflineUnlockRecord();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
@@ -892,7 +1004,7 @@ export default function App() {
const vaultCoreQuery = useQuery({
queryKey: ['vault-core', vaultCacheKey],
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
staleTime: 30_000,
});
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
@@ -903,7 +1015,7 @@ export default function App() {
const sendsQuery = useQuery({
queryKey: sendsQueryKey,
queryFn: () => getSends(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
staleTime: 30_000,
});
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
@@ -932,13 +1044,13 @@ export default function App() {
const usersQuery = useQuery({
queryKey: ['admin-users', vaultCacheKey],
queryFn: () => listAdminUsers(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', vaultCacheKey],
queryFn: () => listAdminInvites(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const totpStatusQuery = useQuery({
@@ -953,10 +1065,95 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
const domainRulesQueryKey = useMemo(() => ['domain-rules', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const domainRulesQuery = useQuery({
queryKey: domainRulesQueryKey,
queryFn: () => getDomainRules(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
const pendingAuthRequestsQuery = useQuery({
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000,
refetchInterval: 15_000,
refetchIntervalInBackground: true,
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
setAuthRequestSubmittingId(authRequest.id);
try {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
setAuthRequestDialogDismissedId(null);
pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestSubmittingId(authRequest.id);
try {
await respondToAuthRequest(authedFetch, authRequest.id, {
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: false,
});
setAuthRequestDialogDismissedId(null);
pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
const currentRules = queryClient.getQueryData<DomainRules>(domainRulesQueryKey) || domainRulesQuery.data;
const optimisticRules: DomainRules = {
object: 'domains',
equivalentDomains,
customEquivalentDomains,
globalEquivalentDomains: (currentRules?.globalEquivalentDomains || []).map((rule) => ({
...rule,
excluded: excludedGlobalTypes.has(rule.type),
})),
};
const saveSeq = ++domainRulesSaveSeqRef.current;
queryClient.setQueryData(domainRulesQueryKey, optimisticRules);
void saveDomainRules(authedFetch, {
customEquivalentDomains,
equivalentDomains,
excludedGlobalEquivalentDomains,
}).then((updated) => {
if (domainRulesSaveSeqRef.current !== saveSeq) return;
queryClient.setQueryData(domainRulesQueryKey, updated);
void queryClient.invalidateQueries({ queryKey: ['vault-core', vaultCacheKey] });
}).catch((error) => {
if (domainRulesSaveSeqRef.current !== saveSeq) return;
pushToast('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
void domainRulesQuery.refetch();
});
return Promise.resolve();
}
useQuery({
queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(),
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
@@ -985,6 +1182,7 @@ export default function App() {
useEffect(() => {
if (session?.accessToken) return;
repairAttemptRef.current = '';
uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]);
useEffect(() => {
@@ -1025,6 +1223,27 @@ export default function App() {
setDecryptedFolders(result.folders);
setDecryptedCiphers(result.ciphers);
setVaultInitialDecryptDone(true);
if (!session.accessToken) return;
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
if (uriChecksumRepairAttemptRef.current !== repairKey) {
uriChecksumRepairAttemptRef.current = repairKey;
void repairCipherKeyMismatches(authedFetch, session, result.ciphers)
.then(async (keyMismatchCount) => {
if (keyMismatchCount > 0) {
await invalidateVaultCoreSyncSnapshot(vaultCacheKey);
void refetchVaultCoreData();
return;
}
const uriChecksumCount = await repairCipherUriChecksums(authedFetch, session, result.ciphers);
if (uriChecksumCount > 0) {
await invalidateVaultCoreSyncSnapshot(vaultCacheKey);
void refetchVaultCoreData();
}
})
.catch(() => {
// Best-effort compatibility repair must not interrupt normal vault loading.
});
}
} catch (error) {
if (!active) return;
const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2');
@@ -1037,7 +1256,7 @@ export default function App() {
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
}, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]);
useEffect(() => {
if (IS_DEMO_MODE) return;
@@ -1272,6 +1491,7 @@ export default function App() {
const accountSecurityActions = useAccountSecurityActions({
authedFetch,
profile,
session,
defaultKdfIterations,
disableTotpPassword,
clearDisableTotpDialog: () => {
@@ -1317,6 +1537,23 @@ export default function App() {
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
const demoDomainRules = useMemo<DomainRules>(() => ({
equivalentDomains: [
['nodewarden.example', 'nw.example'],
['staging.nodewarden.example', 'preview.nodewarden.example'],
],
customEquivalentDomains: [
{ id: 'demo-custom-1', domains: ['nodewarden.example', 'nw.example'], excluded: false },
{ id: 'demo-custom-2', domains: ['staging.nodewarden.example', 'preview.nodewarden.example'], excluded: false },
],
globalEquivalentDomains: [
{ type: 0, domains: ['youtube.com', 'google.com', 'gmail.com'], excluded: false },
{ type: 1, domains: ['apple.com', 'icloud.com'], excluded: false },
{ type: 10, domains: ['microsoft.com', 'office.com', 'xbox.com'], excluded: true },
{ type: -10001, domains: ['nodewarden.example', 'nw.example'], excluded: false },
],
object: 'domains',
}), []);
const mobilePrimaryRoute =
location === '/sends'
? '/sends'
@@ -1329,7 +1566,9 @@ export default function App() {
if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel');
if (location === '/security/devices') return t('nav_device_management');
if (location === '/logs') return t('nav_log_center');
if (location === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) return t('nav_device_management');
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
if (location === '/backup') return t('nav_backup_strategy');
if (isImportRoute) return t('nav_import_export');
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
@@ -1337,10 +1576,26 @@ export default function App() {
return t('nav_my_vault');
})();
useEffect(() => {
if (phase !== 'app') return;
if (!hashPath.startsWith('/')) return;
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
}
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
}, [phase, hashPath, normalizedHashPath, location, navigate]);
useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]);
useEffect(() => {
if (phase === 'register' && (location === '/' || location === '/login') && !isPublicSendRoute) {
navigate('/register');
}
}, [phase, location, isPublicSendRoute, navigate]);
useEffect(() => {
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
navigate(IMPORT_ROUTE);
@@ -1348,7 +1603,7 @@ export default function App() {
}, [phase, isImportHashRoute, location, navigate]);
useEffect(() => {
if (phase === 'app' && !isAdminProfile(profile) && location === '/backup' && !profileQuery.isFetching) {
if (phase === 'app' && !isAdminProfile(profile) && (location === '/backup' || location === '/logs') && !profileQuery.isFetching) {
navigate('/vault');
}
}, [phase, profile?.role, profileQuery.isFetching, location, navigate]);
@@ -1385,6 +1640,9 @@ export default function App() {
authorizedDevices: authorizedDevicesQuery.data || [],
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
domainRulesLoading: domainRulesQuery.isFetching && !domainRulesQuery.data,
domainRulesError: domainRulesQuery.isError && !domainRulesQuery.data ? t('txt_domain_rules_load_failed') : '',
onNavigate: navigate,
onLogout: handleLogout,
onNotify: pushToast,
@@ -1396,6 +1654,7 @@ export default function App() {
onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
@@ -1429,11 +1688,27 @@ export default function App() {
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onListAccountPasskeys: accountSecurityActions.listAccountPasskeys,
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
onApproveAuthRequest: approveAuthRequest,
onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRefreshDomainRules: () => {
void domainRulesQuery.refetch();
},
onSaveDomainRules: handleSaveDomainRules,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
@@ -1443,6 +1718,10 @@ export default function App() {
onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
@@ -1530,17 +1809,26 @@ export default function App() {
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword}
registerValues={registerValues}
registrationInviteRequired={registrationInviteRequired}
unlockPassword={unlockPassword}
emailForLock={profile?.email || session?.email || ''}
loginHintLoading={loginHintState.loading}
onChangeLogin={setLoginValues}
onChangePasskeyPassword={setPasskeyPassword}
onChangeRegister={setRegisterValues}
onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()}
onSubmitPasskey={() => void handlePasskeyLogin()}
onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()}
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => {
setPendingPasskeyPassword(null);
setPasskeyPassword('');
setPhase('login');
navigate('/login');
}}
@@ -1552,6 +1840,8 @@ export default function App() {
if (inviteCodeFromUrl) {
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
}
setPendingPasskeyPassword(null);
setPasskeyPassword('');
setPhase('register');
navigate('/register');
}}
@@ -1653,6 +1943,24 @@ export default function App() {
}}
disableTotpSubmitting={disableTotpSubmitting}
/>
<AuthRequestApprovalDialog
open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest}
submitting={!!authRequestSubmittingId}
onApprove={() => {
if (!latestPendingAuthRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onDeny={() => {
if (!latestPendingAuthRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
/>
</>
);
}
+11 -9
View File
@@ -129,15 +129,20 @@ export default function AdminPage(props: AdminPageProps) {
</table>
</section>
<section className="card">
<div className="section-head">
<section className="card admin-invites-card">
<div className="section-head admin-invites-head">
<h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
<div className="actions admin-invites-head-actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
</button>
</div>
</div>
<div className="invite-toolbar">
<div className="actions invite-create-group">
<div className="invite-create-group">
<label className="field invite-hours-field">
<span>{t('txt_invite_validity_hours')}</span>
<input
@@ -154,11 +159,8 @@ export default function AdminPage(props: AdminPageProps) {
{t('txt_create_timed_invite')}
</button>
</div>
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
</button>
</div>
<table className="table">
<table className="table invite-table">
<thead>
<tr>
<th>{t('txt_code')}</th>
@@ -207,7 +209,7 @@ export default function AdminPage(props: AdminPageProps) {
)}
</tbody>
</table>
<div className="actions">
<div className="actions admin-pagination invite-pagination">
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
+235 -37
View File
@@ -1,6 +1,9 @@
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n';
@@ -25,13 +28,209 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps;
}
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
function readNavLayoutMode(): NavLayoutMode {
if (typeof window === 'undefined') return 'flat';
try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
} catch {
// Ignore local preference read failures.
}
return 'flat';
}
function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin';
}
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute;
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!navLayoutPickerOpen) return;
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
} catch {
// Ignore local preference write failures.
}
}
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
}
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
return (
<Link href={href} className={`side-link ${active ? 'active' : ''}`}>
{icon}
<span>{label}</span>
</Link>
);
}
function renderSubLink(href: string, active: boolean, label: string) {
return (
<Link href={href} className={`side-sub-link ${active ? 'active' : ''}`}>
<span>{label}</span>
</Link>
);
}
function renderNavGroup(
group: keyof typeof expandedGroups,
title: string,
icon: ComponentChildren,
active: boolean,
children: ComponentChildren
) {
const open = groupOpen(group, active);
return (
<div className={`side-nav-group ${open ? 'open' : ''}`}>
<button
type="button"
className={`side-group-trigger ${active ? 'active' : ''}`}
aria-expanded={open}
onClick={() => toggleGroup(group)}
>
{icon}
<span>{title}</span>
<ChevronDown size={15} className="side-group-chevron" />
</button>
<div className={`side-subnav ${open ? 'open' : ''}`}>
<div className="side-subnav-inner">
{children}
</div>
</div>
</div>
);
}
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</>
);
const groupedNav = (
<>
{renderNavGroup(
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
</>
)}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup(
'settings',
t('txt_settings'),
<SettingsIcon size={16} />,
settingsActive,
<>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</>
)}
{renderNavGroup(
'management',
t('nav_group_management'),
<ShieldUser size={16} />,
managementActive,
<>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</>
)}
</>
);
return (
<div className="app-page">
@@ -43,6 +242,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<span className="mobile-page-title">{props.currentPageTitle}</span>
</div>
<div className="topbar-actions">
<NetworkStatusBadge />
<div className="user-chip">
<ShieldUser size={16} />
<span>{props.profile?.email}</span>
@@ -76,45 +276,43 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main">
<aside className="app-side">
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
<KeyRound size={16} />
<span>{t('nav_my_vault')}</span>
</Link>
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
<Clock3 size={16} />
<span>{t('txt_verification_code')}</span>
</Link>
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
<SendIcon size={16} />
<span>{t('nav_sends')}</span>
</Link>
{isAdmin && (
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
<span>{t('nav_admin_panel')}</span>
</Link>
<div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
<SettingsIcon size={16} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
<Shield size={16} />
<span>{t('nav_device_management')}</span>
</Link>
{isAdmin && (
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
<Cloud size={16} />
<span>{t('nav_backup_strategy')}</span>
</Link>
)}
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
<ArrowUpDown size={14} />
<span>{t('nav_import_export')}</span>
</Link>
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
</div>
</aside>
<main className="content">
<div key={routeAnimationKey} className="route-stage">
<div key={routeAnimationKey} className={`route-stage ${isDomainRulesRoute ? 'route-stage-fixed' : ''} ${isLogRoute ? 'route-stage-log-fixed' : ''}`}>
<AppMainRoutes {...props.mainRoutesProps} />
</div>
</main>
+2 -1
View File
@@ -12,6 +12,7 @@ export interface AppConfirmState {
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
onCancel?: () => void;
}
interface AppGlobalOverlaysProps {
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm}
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/>
<ConfirmDialog
+95 -4
View File
@@ -1,21 +1,24 @@
import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage'));
const LogCenterPage = lazy(() => import('@/components/LogCenterPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage'));
@@ -56,6 +59,9 @@ export interface AppMainRoutesProps {
authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean;
authorizedDevicesError: string;
domainRules: DomainRules | null;
domainRulesLoading: boolean;
domainRulesError: string;
onNavigate: (path: string) => void;
onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -75,6 +81,7 @@ export interface AppMainRoutesProps {
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
@@ -105,11 +112,23 @@ export interface AppMainRoutesProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>;
onRefreshDomainRules: () => void;
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void;
@@ -119,6 +138,10 @@ export interface AppMainRoutesProps {
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
@@ -135,6 +158,7 @@ export interface AppMainRoutesProps {
export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
@@ -201,6 +225,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onRestore={props.onRestoreVaultItems}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
@@ -246,6 +271,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
onListAccountPasskeys={props.onListAccountPasskeys}
onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify}
@@ -264,10 +298,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className="mobile-settings-link">
<Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} />
<span>{t('nav_device_management')}</span>
</Link>
<Link href="/settings/domain-rules" className="mobile-settings-link">
<Globe2 size={18} />
<span>{t('nav_domain_rules')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
@@ -278,6 +316,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<span>{t('nav_admin_panel')}</span>
</Link>
)}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
)}
{isAdmin && (
<Link href="/backup" className="mobile-settings-link">
<Cloud size={18} />
@@ -294,7 +338,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LoadingState card lines={4} />
) : null}
</Route>
<Route path="/security/devices">
{deviceManagementRoutePaths.map((path) => (
<Route key={path} path={path}>
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
@@ -309,9 +354,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
@@ -319,6 +370,29 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense>
</div>
</Route>
))}
<Route path="/settings/domain-rules">
<div className="stack domain-rules-route">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<DomainRulesPage
rules={props.domainRules}
loading={props.domainRulesLoading}
error={props.domainRulesError}
onRefresh={props.onRefreshDomainRules}
onSave={props.onSaveDomainRules}
onNotify={props.onNotify}
/>
</Suspense>
</div>
</Route>
<Route path="/admin">
<div className="stack">
{props.mobileLayout && (
@@ -346,6 +420,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense>
</div>
</Route>
<Route path="/logs">
{isAdmin ? (
<div className="stack">
<Suspense fallback={<RouteContentFallback />}>
<LogCenterPage
onLoadLogs={props.onLoadAuditLogs}
onLoadSettings={props.onLoadAuditLogSettings}
onSaveSettings={props.onSaveAuditLogSettings}
onClearLogs={props.onClearAuditLogs}
onNotify={props.onNotify}
mobileLayout={props.mobileLayout}
onMobileBack={() => props.onNavigate(props.settingsHomeRoute)}
/>
</Suspense>
</div>
) : null}
</Route>
{importRoutePaths.map((path) => (
<Route key={path} path={path}>
{renderImportPageRoute()}
@@ -0,0 +1,74 @@
import { ShieldCheck, ShieldX } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import { t } from '@/lib/i18n';
import type { AuthRequest } from '@/lib/types';
interface AuthRequestApprovalDialogProps {
open: boolean;
authRequest: AuthRequest | null;
submitting: boolean;
onApprove: () => void;
onDeny: () => void;
onClose: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
const authRequest = props.authRequest;
return (
<ConfirmDialog
open={props.open && !!authRequest}
title={t('txt_approve_device_login')}
message={t('txt_auth_request_approve_message')}
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
cancelText={t('txt_later')}
confirmDisabled={props.submitting || !authRequest}
cancelDisabled={props.submitting}
onConfirm={props.onApprove}
onCancel={props.onClose}
afterActions={(
<button
type="button"
className="btn btn-danger dialog-btn"
disabled={props.submitting || !authRequest}
onClick={props.onDeny}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
)}
>
{authRequest && (
<div className="auth-request-details">
<div className="auth-request-device">
<ShieldCheck size={18} />
<div>
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
</div>
</div>
<div className="auth-request-kv">
<span>{t('txt_created')}</span>
<strong>{formatDateTime(authRequest.creationDate)}</strong>
</div>
{authRequest.requestIpAddress && (
<div className="auth-request-kv">
<span>{t('txt_ip_address')}</span>
<strong>{authRequest.requestIpAddress}</strong>
</div>
)}
<div className="auth-request-fingerprint">
<span>{t('txt_fingerprint_phrase')}</span>
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
</div>
</div>
)}
</ConfirmDialog>
);
}
+68 -12
View File
@@ -1,5 +1,6 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -22,18 +23,25 @@ interface AuthViewsProps {
relaxedLoginInput?: boolean;
authPlaceholder?: string;
unlockPlaceholder?: string;
pendingAction: 'login' | 'register' | 'unlock' | null;
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean;
unlockPreparing: boolean;
loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string;
registerValues: RegisterValues;
registrationInviteRequired?: boolean;
unlockPassword: string;
emailForLock: string;
loginHintLoading: boolean;
onChangeLogin: (next: LoginValues) => void;
onChangePasskeyPassword: (password: string) => void;
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void;
onSubmitPasskey: () => void;
onSubmitPasskeyUnlock: () => void;
onSubmitPasskeyPassword: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
@@ -75,13 +83,16 @@ function PasswordField(props: {
export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey';
const registerBusy = props.pendingAction === 'register';
const unlockBusy = props.pendingAction === 'unlock';
const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail;
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
if (props.mode === 'locked') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}>
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -112,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button>
<button
type="button"
className="btn btn-secondary full"
onClick={props.onSubmitPasskeyUnlock}
disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}
>
<KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_unlocking') : t('txt_unlock_with_passkey')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy || passkeyBusy}>
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
@@ -130,7 +150,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'register') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}>
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -184,8 +204,9 @@ export default function AuthViews(props: AuthViewsProps) {
}
/>
</label>
{showInviteCodeField ? (
<label className="field">
<span>{t('txt_invite_code_optional')}</span>
<span>{t('txt_invite_code_required')}</span>
<input
className="input"
value={props.registerValues.inviteCode}
@@ -195,6 +216,7 @@ export default function AuthViews(props: AuthViewsProps) {
}
/>
</label>
) : null}
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
<UserPlus size={16} className="btn-icon" />
{registerBusy ? t('txt_registering') : t('txt_create_account')}
@@ -212,13 +234,41 @@ export default function AuthViews(props: AuthViewsProps) {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}>
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
<form
onSubmit={(e) => {
e.preventDefault();
if (passkeyPasswordPending) {
props.onSubmitPasskeyPassword();
return;
}
props.onSubmitLogin();
}}
>
{passkeyPasswordPending ? (
<>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
<input type="text" value={props.pendingPasskeyPasswordEmail || ''} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
label={t('txt_master_password')}
value={props.passkeyPassword}
autoFocus
autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={props.onChangePasskeyPassword}
/>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<Unlock size={16} className="btn-icon" />
{loginBusy ? t('txt_unlocking') : t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={loginBusy}>
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
</>
) : (
<>
<label className="field">
<span>{t('txt_email')}</span>
<input
@@ -227,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
value={props.loginValues.email}
autoComplete="username"
placeholder={props.authPlaceholder}
autoFocus
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
@@ -236,7 +287,6 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<div className="auth-support-row">
<span />
@@ -244,22 +294,28 @@ export default function AuthViews(props: AuthViewsProps) {
type="button"
className="auth-link-btn"
onClick={props.onTogglePasswordHint}
disabled={loginBusy || !props.loginValues.email.trim()}
disabled={loginBusy || props.loginHintLoading || !props.loginValues.email.trim()}
>
{props.loginHintLoading
? t('txt_loading_password_hint')
: t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<button type="submit" className="btn btn-primary full" disabled={loginBusy || passkeyBusy}>
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button>
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}>
<KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy || passkeyBusy}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
</>
)}
</form>
</StandalonePageFrame>
</div>
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal((
<div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return;
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel();
}}
>
+529
View File
@@ -0,0 +1,529 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Check, ChevronDown, ChevronUp, ExternalLink, Pencil, Plus, RefreshCw, Save, Trash2, X } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import { t } from '@/lib/i18n';
import type { CustomEquivalentDomain, DomainRules } from '@/lib/types';
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
const CUSTOM_GLOBAL_DOMAINS_PR_URL = 'https://github.com/shuaiplus/nodewarden/edit/main/src/static/global_domains.custom.json';
interface DomainRulesPageProps {
rules: DomainRules | null;
loading: boolean;
error: string;
onRefresh: () => void;
onSave: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
interface DomainRuleSummaryProps {
text: string;
expanded: boolean;
onToggle: () => void;
}
function normalizeDomain(value: string): string {
return normalizeEquivalentDomain(value);
}
function normalizeDomainList(domains: string[]): string[] {
return Array.from(new Set(domains.map(normalizeDomain).filter(Boolean)));
}
function isValidDomainName(value: string): boolean {
return !!normalizeEquivalentDomain(value);
}
function getInvalidDomainIndexes(domains: string[]): Set<number> {
const invalid = new Set<number>();
domains.forEach((domain, index) => {
if (!isValidDomainName(domain)) invalid.add(index);
});
return invalid;
}
function createDraftId(): string {
return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function createEmptyDomains(): string[] {
return ['', ''];
}
function DomainRuleSummary(props: DomainRuleSummaryProps) {
const textRef = useRef<HTMLSpanElement>(null);
const [canExpand, setCanExpand] = useState(false);
useEffect(() => {
const node = textRef.current;
if (!node) return undefined;
const measure = () => {
const width = node.getBoundingClientRect().width;
if (!width || typeof document === 'undefined') {
setCanExpand(false);
return;
}
const probe = document.createElement('span');
const styles = window.getComputedStyle(node);
probe.textContent = props.text;
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.whiteSpace = 'nowrap';
probe.style.font = styles.font;
probe.style.letterSpacing = styles.letterSpacing;
probe.style.left = '-9999px';
probe.style.top = '-9999px';
document.body.appendChild(probe);
const fullWidth = probe.getBoundingClientRect().width;
probe.remove();
setCanExpand(fullWidth > width + 1);
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [props.text]);
return (
<>
<span
ref={textRef}
className={`domain-rule-domains${props.expanded ? ' domain-rule-domains-expanded' : ''}`}
>
{props.text}
</span>
{canExpand && (
<button
type="button"
className="domain-rule-expand-btn"
title={props.expanded ? t('txt_collapse') : t('txt_expand')}
aria-label={props.expanded ? t('txt_collapse') : t('txt_expand')}
onClick={props.onToggle}
>
{props.expanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</button>
)}
</>
);
}
function toEditableCustomRules(rules: DomainRules | null): CustomEquivalentDomain[] {
const source = rules?.customEquivalentDomains?.length
? rules.customEquivalentDomains
: (rules?.equivalentDomains || []).map((domains, index) => ({
id: `custom-${index}`,
domains,
excluded: false,
}));
return source.map((rule, index) => ({
id: String(rule.id || `custom-${index}`),
domains: rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains(),
excluded: !!rule.excluded,
}));
}
export default function DomainRulesPage(props: DomainRulesPageProps) {
const [customRules, setCustomRules] = useState<CustomEquivalentDomain[]>([]);
const [newRuleDomains, setNewRuleDomains] = useState<string[] | null>(null);
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
const [editingDomains, setEditingDomains] = useState<string[]>(createEmptyDomains);
const [newRuleInvalidIndexes, setNewRuleInvalidIndexes] = useState<Set<number>>(new Set());
const [editingInvalidIndexes, setEditingInvalidIndexes] = useState<Set<number>>(new Set());
const [excludedTypes, setExcludedTypes] = useState<Set<number>>(new Set());
const [expandedCustomRules, setExpandedCustomRules] = useState<Set<string>>(new Set());
const [expandedGlobalRules, setExpandedGlobalRules] = useState<Set<number>>(new Set());
const [saving, setSaving] = useState(false);
const [filter, setFilter] = useState('');
useEffect(() => {
setCustomRules(toEditableCustomRules(props.rules));
setNewRuleDomains(null);
setEditingRuleId(null);
setEditingDomains(createEmptyDomains());
setNewRuleInvalidIndexes(new Set());
setEditingInvalidIndexes(new Set());
setExpandedCustomRules(new Set());
setExpandedGlobalRules(new Set());
setExcludedTypes(new Set((props.rules?.globalEquivalentDomains || []).filter((entry) => entry.excluded).map((entry) => entry.type)));
}, [props.rules]);
const sortedGlobals = useMemo(() => {
return [...(props.rules?.globalEquivalentDomains || [])].sort((a, b) => {
const aKey = a.domains[0] || '';
const bKey = b.domains[0] || '';
return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' });
});
}, [props.rules]);
const filteredGlobals = useMemo(() => {
const needle = filter.trim().toLowerCase();
if (!needle) return sortedGlobals;
return sortedGlobals.filter((entry) => entry.domains.some((domain) => domain.includes(needle)));
}, [filter, sortedGlobals]);
function setCustomRuleEnabled(index: number, enabled: boolean): void {
setCustomRules((rules) => rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, excluded: !enabled } : rule));
}
function beginEditCustomRule(rule: CustomEquivalentDomain): void {
setNewRuleDomains(null);
setEditingRuleId(rule.id);
setEditingDomains(rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains());
setEditingInvalidIndexes(new Set());
}
function confirmEditCustomRule(): void {
if (!editingRuleId) return;
const invalidIndexes = getInvalidDomainIndexes(editingDomains);
setEditingInvalidIndexes(invalidIndexes);
if (invalidIndexes.size) {
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
return;
}
const domains = normalizeDomainList(editingDomains);
if (domains.length < 2) {
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
return;
}
setCustomRules((rules) => rules.map((rule) => rule.id === editingRuleId ? { ...rule, domains } : rule));
setEditingRuleId(null);
setEditingDomains(createEmptyDomains());
}
function cancelEditCustomRule(): void {
setEditingRuleId(null);
setEditingDomains(createEmptyDomains());
setEditingInvalidIndexes(new Set());
}
function addNewRule(): void {
const invalidIndexes = getInvalidDomainIndexes(newRuleDomains || []);
setNewRuleInvalidIndexes(invalidIndexes);
if (invalidIndexes.size) {
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
return;
}
const domains = normalizeDomainList(newRuleDomains || []);
if (domains.length < 2) {
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
return;
}
setCustomRules((rules) => [
{
id: createDraftId(),
domains,
excluded: false,
},
...rules,
]);
setNewRuleDomains(null);
setNewRuleInvalidIndexes(new Set());
}
function removeCustomRule(index: number): void {
setCustomRules((rules) => rules.filter((_, currentIndex) => currentIndex !== index));
}
function toggleGlobal(type: number): void {
setExcludedTypes((current) => {
const next = new Set(current);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
}
function toggleExpandedCustomRule(id: string): void {
setExpandedCustomRules((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleExpandedGlobalRule(type: number): void {
setExpandedGlobalRules((current) => {
const next = new Set(current);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}
async function save(): Promise<void> {
const normalizedCustomRules = customRules.map((rule) => ({
...rule,
domains: normalizeDomainList(rule.domains),
}));
if (normalizedCustomRules.some((rule) => rule.domains.some((domain) => !isValidDomainName(domain)))) {
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
return;
}
if (normalizedCustomRules.some((rule) => rule.domains.length < 2)) {
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
return;
}
const excludedGlobalEquivalentDomains = (props.rules?.globalEquivalentDomains || [])
.filter((entry) => excludedTypes.has(entry.type))
.map((entry) => entry.type);
setSaving(true);
try {
await props.onSave(normalizedCustomRules, excludedGlobalEquivalentDomains);
props.onNotify('success', t('txt_domain_rules_saved'));
} catch (error) {
props.onNotify('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
} finally {
setSaving(false);
}
}
function renderDomainInputs(domains: string[], invalidIndexes: Set<number>, onChange: (index: number, value: string) => void, onAdd: () => void, onRemove?: (index: number) => void) {
return (
<div className="domain-rule-inputs">
{domains.map((domain, index) => (
<div key={index} className="domain-rule-input-piece">
<input
className={`input domain-rule-inline-input${invalidIndexes.has(index) ? ' domain-rule-input-invalid' : ''}`}
value={domain}
placeholder="example.com"
aria-invalid={invalidIndexes.has(index)}
onInput={(event) => onChange(index, (event.currentTarget as HTMLInputElement).value)}
/>
{domains.length > 2 && onRemove && (
<button
type="button"
className="domain-rule-input-remove"
title={t('txt_remove_domain')}
aria-label={t('txt_remove_domain')}
onClick={() => onRemove(index)}
>
<X size={13} />
</button>
)}
{index < domains.length - 1 && <span className="domain-rule-operator">,</span>}
</div>
))}
<button
type="button"
className="btn btn-secondary small domain-rule-mini-btn"
title={t('txt_add_domain')}
aria-label={t('txt_add_domain')}
onClick={onAdd}
>
<Plus size={14} />
</button>
</div>
);
}
if (props.loading && !props.rules) {
return <LoadingState card lines={6} />;
}
return (
<div className="domain-rules-page">
<div className="domain-rules-toolbar">
<div className="domain-rules-toolbar-copy">
<div className="domain-rules-toolbar-title">{t('nav_domain_rules')}</div>
<p>{t('txt_domain_rules_description')}</p>
</div>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
<Save size={14} className="btn-icon" />
{saving ? t('txt_saving') : t('txt_save')}
</button>
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_sync')}
</button>
<a className="btn btn-secondary" href={CUSTOM_GLOBAL_DOMAINS_PR_URL} target="_blank" rel="noreferrer">
<ExternalLink size={14} className="btn-icon" />
{t('txt_submit_pr')}
</a>
</div>
</div>
<div className="settings-modules-grid domain-rules-grid">
<section className="card settings-module domain-rules-custom">
<div className="section-heading-row">
<h3>{t('txt_custom_equivalent_domains')}</h3>
<button type="button" className="btn btn-secondary small" onClick={() => {
setEditingRuleId(null);
setEditingInvalidIndexes(new Set());
setNewRuleDomains((current) => current || createEmptyDomains());
setNewRuleInvalidIndexes(new Set());
}}>
<Plus size={14} className="btn-icon" />
{t('txt_add')}
</button>
</div>
{props.error && <div className="status-error">{props.error}</div>}
{newRuleDomains && (
<div className="domain-rule-row domain-rule-editing-row domain-rule-new-row">
<div className="domain-rule-main">
{renderDomainInputs(
newRuleDomains,
newRuleInvalidIndexes,
(index, value) => {
setNewRuleDomains((domains) => (domains || createEmptyDomains()).map((domain, currentIndex) => currentIndex === index ? value : domain));
setNewRuleInvalidIndexes((current) => {
const next = new Set(current);
next.delete(index);
return next;
});
},
() => {
setNewRuleDomains((domains) => [...(domains || createEmptyDomains()), '']);
setNewRuleInvalidIndexes(new Set());
},
(index) => setNewRuleDomains((domains) => {
const current = domains || createEmptyDomains();
setNewRuleInvalidIndexes(new Set());
return current.length > 2 ? current.filter((_, currentIndex) => currentIndex !== index) : current;
})
)}
</div>
<div className="domain-rule-row-actions">
<button type="button" className="btn btn-primary small" onClick={addNewRule}>
<Check size={14} className="btn-icon" />
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => {
setNewRuleDomains(null);
setNewRuleInvalidIndexes(new Set());
}}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</div>
)}
<div className="domain-rules-table">
{customRules.map((rule, ruleIndex) => (
editingRuleId === rule.id ? (
<div key={rule.id} className="domain-rule-row domain-rule-editing-row">
<div className="domain-rule-main">
{renderDomainInputs(
editingDomains,
editingInvalidIndexes,
(domainIndex, value) => {
setEditingDomains((domains) => domains.map((domain, currentIndex) => currentIndex === domainIndex ? value : domain));
setEditingInvalidIndexes((current) => {
const next = new Set(current);
next.delete(domainIndex);
return next;
});
},
() => {
setEditingDomains((domains) => [...domains, '']);
setEditingInvalidIndexes(new Set());
},
(domainIndex) => {
setEditingInvalidIndexes(new Set());
setEditingDomains((domains) => domains.length > 2 ? domains.filter((_, currentIndex) => currentIndex !== domainIndex) : domains);
}
)}
</div>
<div className="domain-rule-row-actions">
<button type="button" className="btn btn-primary small" onClick={confirmEditCustomRule}>
<Check size={14} className="btn-icon" />
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary small" onClick={cancelEditCustomRule}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</div>
) : (
<div key={rule.id} className={`domain-rule-row${expandedCustomRules.has(rule.id) ? ' domain-rule-row-expanded' : ''}`}>
<input
type="checkbox"
checked={!rule.excluded}
aria-label={t('txt_enabled')}
onChange={(event) => setCustomRuleEnabled(ruleIndex, (event.currentTarget as HTMLInputElement).checked)}
/>
<DomainRuleSummary
text={rule.domains.join(', ')}
expanded={expandedCustomRules.has(rule.id)}
onToggle={() => toggleExpandedCustomRule(rule.id)}
/>
<div className="domain-rule-row-actions">
<button
type="button"
className="btn btn-secondary small domain-rule-icon-btn"
title={t('txt_edit')}
aria-label={t('txt_edit')}
onClick={() => beginEditCustomRule(rule)}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-secondary small domain-rule-icon-btn"
title={t('txt_delete')}
aria-label={t('txt_delete')}
onClick={() => removeCustomRule(ruleIndex)}
>
<Trash2 size={14} />
</button>
</div>
</div>
)
))}
{!customRules.length && !newRuleDomains && <div className="empty empty-comfortable">{t('txt_no_custom_domain_rules')}</div>}
</div>
</section>
<section className="card settings-module domain-rules-global">
<div className="section-heading-row">
<h3>{t('txt_global_equivalent_domains')}</h3>
<div className="domain-rules-heading-actions">
<input
className="input domain-rules-filter"
value={filter}
placeholder={t('txt_search_domains')}
onInput={(event) => setFilter((event.currentTarget as HTMLInputElement).value)}
/>
</div>
</div>
<div className="domain-rules-table">
{filteredGlobals.map((entry) => (
<div key={entry.type} className={`domain-rule-row domain-rule-readonly-row${expandedGlobalRules.has(entry.type) ? ' domain-rule-row-expanded' : ''}`}>
<input
type="checkbox"
checked={!excludedTypes.has(entry.type)}
onChange={() => toggleGlobal(entry.type)}
/>
<DomainRuleSummary
text={entry.domains.join(', ')}
expanded={expandedGlobalRules.has(entry.type)}
onToggle={() => toggleExpandedGlobalRule(entry.type)}
/>
</div>
))}
{!filteredGlobals.length && <div className="empty empty-comfortable">{t('txt_no_domain_rules_found')}</div>}
</div>
</section>
</div>
</div>
);
}
+1
View File
@@ -91,6 +91,7 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'lastpass',
'dashlane_csv',
'dashlane_json',
'keepass_csv',
'keepass_xml',
'keepassx_csv',
];
+40
View File
@@ -0,0 +1,40 @@
export function CardSkeleton() {
return (
<div className="skeleton-card">
<div className="skeleton-avatar" />
<div className="skeleton-content">
<div className="skeleton-line skeleton-line-lg" />
<div className="skeleton-line" />
</div>
</div>
);
}
export function ListSkeleton({ count = 5 }: { count?: number }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-list-item">
<div className="skeleton-icon" />
<div className="skeleton-content">
<div className="skeleton-line skeleton-line-md" />
<div className="skeleton-line skeleton-line-sm" />
</div>
</div>
))}
</>
);
}
export function PageSkeleton() {
return (
<div className="skeleton-page">
<div className="skeleton-header">
<div className="skeleton-line skeleton-line-xl" />
</div>
<div className="skeleton-body">
<ListSkeleton />
</div>
</div>
);
}
+578
View File
@@ -0,0 +1,578 @@
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Database, RefreshCw, Save, Search, Server, Settings2, ShieldAlert, Smartphone, Trash2, UserRound } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { AuditLogFilters } from '@/lib/api/admin';
import { t } from '@/lib/i18n';
import type { AuditLogCategory, AuditLogEntry, AuditLogLevel, AuditLogListResult, AuditLogSettings } from '@/lib/types';
interface LogCenterPageProps {
onLoadLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadSettings: () => Promise<AuditLogSettings>;
onSaveSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearLogs: () => Promise<number>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
mobileLayout?: boolean;
onMobileBack?: () => void;
}
type TimeRange = '24h' | '7d' | '30d' | 'all';
type FilterCategory = AuditLogCategory | 'all';
type FilterLevel = AuditLogLevel | 'all';
type RetentionMode = 'days' | 'entries';
const PAGE_SIZE = 50;
const CATEGORY_OPTIONS: Array<{ value: FilterCategory; labelKey: string }> = [
{ value: 'all', labelKey: 'txt_all_logs' },
{ value: 'auth', labelKey: 'txt_log_category_auth' },
{ value: 'security', labelKey: 'txt_log_category_security' },
{ value: 'device', labelKey: 'txt_log_category_device' },
{ value: 'data', labelKey: 'txt_log_category_data' },
{ value: 'system', labelKey: 'txt_log_category_system' },
];
const LEVEL_OPTIONS: Array<{ value: FilterLevel; labelKey: string }> = [
{ value: 'all', labelKey: 'txt_all_levels' },
{ value: 'info', labelKey: 'txt_log_level_info' },
{ value: 'warn', labelKey: 'txt_log_level_warn' },
{ value: 'error', labelKey: 'txt_log_level_error' },
{ value: 'security', labelKey: 'txt_log_level_security' },
];
const RANGE_OPTIONS: Array<{ value: TimeRange; labelKey: string }> = [
{ value: '24h', labelKey: 'txt_last_24_hours' },
{ value: '7d', labelKey: 'txt_last_7_days' },
{ value: '30d', labelKey: 'txt_last_30_days' },
{ value: 'all', labelKey: 'txt_all_time' },
];
const RETENTION_OPTIONS: Array<{ value: string; labelKey: string }> = [
{ value: '7', labelKey: 'txt_log_retention_7d' },
{ value: '30', labelKey: 'txt_log_retention_30d' },
{ value: '90', labelKey: 'txt_log_retention_90d' },
{ value: '180', labelKey: 'txt_log_retention_180d' },
{ value: '365', labelKey: 'txt_log_retention_365d' },
{ value: '0', labelKey: 'txt_log_retention_forever' },
];
const MAX_ENTRY_OPTIONS: Array<{ value: string; labelKey: string }> = [
{ value: '1000', labelKey: 'txt_log_max_1000' },
{ value: '5000', labelKey: 'txt_log_max_5000' },
{ value: '10000', labelKey: 'txt_log_max_10000' },
{ value: '50000', labelKey: 'txt_log_max_50000' },
{ value: '0', labelKey: 'txt_log_max_unlimited' },
];
function parseMetadata(log: AuditLogEntry): Record<string, unknown> {
if (!log.metadata) return {};
try {
const parsed = JSON.parse(log.metadata);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
} catch {
return { raw: log.metadata };
}
}
function inferCategory(log: AuditLogEntry, metadata: Record<string, unknown>): AuditLogCategory {
if (log.category === 'auth' || log.category === 'security' || log.category === 'device' || log.category === 'data' || log.category === 'system') {
return log.category;
}
const category = metadata.category;
if (category === 'auth' || category === 'security' || category === 'device' || category === 'data' || category === 'system') {
return category;
}
if (log.action.startsWith('auth.')) return 'auth';
if (log.action.startsWith('device.')) return 'device';
if (log.action.startsWith('admin.backup.')) return 'data';
if (log.action.startsWith('account.') || log.action.startsWith('user.password.') || log.action.startsWith('user.register.') || log.action.startsWith('admin.user.')) return 'security';
return 'system';
}
function inferLevel(log: AuditLogEntry, metadata: Record<string, unknown>): AuditLogLevel {
if (log.level === 'info' || log.level === 'warn' || log.level === 'error' || log.level === 'security') {
return log.level;
}
const level = metadata.level;
if (level === 'info' || level === 'warn' || level === 'error' || level === 'security') return level;
if (log.action.includes('.failed') || log.action.includes('.error')) return 'error';
if (log.action.includes('password') || log.action.includes('totp') || log.action.includes('delete') || log.action.includes('ban')) return 'security';
return 'info';
}
function humanizeIdentifier(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split('.')
.flatMap((part) => part.split('_'))
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' / ');
}
function keyFor(prefix: string, value: string): string {
return `${prefix}${value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()}`;
}
function translatedOrHumanized(key: string, fallback: string): string {
const translated = t(key);
return translated === key ? humanizeIdentifier(fallback) : translated;
}
function formatAction(action: string): string {
if (action.startsWith('auth.refresh.failed.')) {
const reason = formatReason(action.slice('auth.refresh.failed.'.length));
return t('txt_log_action_auth_refresh_failed', { reason });
}
return translatedOrHumanized(keyFor('txt_log_action_', action), action);
}
function formatMetaKey(key: string): string {
return translatedOrHumanized(keyFor('txt_log_meta_', key), key);
}
function formatReason(reason: string): string {
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
}
function formatTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function formatMetaValue(value: unknown): string {
if (value === null || value === undefined || value === '') return t('txt_dash');
if (typeof value === 'boolean') return value ? t('txt_yes') : t('txt_no');
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return JSON.stringify(value);
}
function formatMetaValueForKey(key: string, value: unknown): string {
if (key === 'reason' && typeof value === 'string') return formatReason(value);
if (key === 'trigger' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
}
if (key === 'type' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
}
return formatMetaValue(value);
}
function iconForCategory(category: AuditLogCategory) {
if (category === 'auth') return <ShieldAlert size={16} />;
if (category === 'security') return <UserRound size={16} />;
if (category === 'device') return <Smartphone size={16} />;
if (category === 'data') return <Database size={16} />;
return <Server size={16} />;
}
function buildRange(range: TimeRange): { from?: string; to?: string } {
if (range === 'all') return {};
const now = Date.now();
const hours = range === '24h' ? 24 : range === '7d' ? 24 * 7 : 24 * 30;
return {
from: new Date(now - hours * 60 * 60 * 1000).toISOString(),
to: new Date(now).toISOString(),
};
}
function inferRetentionMode(settings: AuditLogSettings): RetentionMode {
return settings.retentionDays === null && settings.maxEntries !== null ? 'entries' : 'days';
}
export default function LogCenterPage(props: LogCenterPageProps) {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<FilterCategory>('all');
const [level, setLevel] = useState<FilterLevel>('all');
const [range, setRange] = useState<TimeRange>('7d');
const [loading, setLoading] = useState(false);
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [retentionMode, setRetentionMode] = useState<RetentionMode>('days');
const [settings, setSettings] = useState<AuditLogSettings>({ retentionDays: 90, maxEntries: null });
const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const selectedLog = useMemo(() => logs.find((log) => log.id === selectedId) || logs[0] || null, [logs, selectedId]);
const selectedMetadata = useMemo(() => selectedLog ? parseMetadata(selectedLog) : {}, [selectedLog]);
const selectedCategory = selectedLog ? inferCategory(selectedLog, selectedMetadata) : 'system';
const selectedLevel = selectedLog ? inferLevel(selectedLog, selectedMetadata) : 'info';
const page = Math.floor(offset / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const load = useCallback(async (nextOffset = offset) => {
setLoading(true);
setError('');
try {
const rangeFilter = buildRange(range);
const result = await props.onLoadLogs({
limit: PAGE_SIZE,
offset: nextOffset,
category,
level,
q: search,
...rangeFilter,
});
setLogs(result.logs);
setTotal(result.total);
setHasMore(result.hasMore);
setOffset(result.offset);
setSelectedId((current) => current && result.logs.some((log) => log.id === current) ? current : result.logs[0]?.id || null);
setMobileDetailOpen(false);
} catch {
setError(t('txt_load_logs_failed'));
props.onNotify('error', t('txt_load_logs_failed'));
} finally {
setLoading(false);
}
}, [category, level, offset, props, range, search]);
useEffect(() => {
void load(0);
}, [category, level, range]);
useEffect(() => {
let cancelled = false;
setSettingsLoading(true);
props.onLoadSettings()
.then((next) => {
if (!cancelled) {
setSettings(next);
setRetentionMode(inferRetentionMode(next));
}
})
.catch(() => {
if (!cancelled) props.onNotify('error', t('txt_load_log_settings_failed'));
})
.finally(() => {
if (!cancelled) setSettingsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
function submitFilters(event: Event): void {
event.preventDefault();
void load(0);
}
async function saveSettings(): Promise<void> {
setSettingsSaving(true);
try {
const next = await props.onSaveSettings(settings);
setSettings(next);
setRetentionMode(inferRetentionMode(next));
setSettingsOpen(false);
setClearConfirmOpen(false);
props.onNotify('success', t('txt_log_settings_saved'));
void load(0);
} catch {
props.onNotify('error', t('txt_log_settings_save_failed'));
} finally {
setSettingsSaving(false);
}
}
async function clearLogs(): Promise<void> {
setSettingsSaving(true);
try {
await props.onClearLogs();
setLogs([]);
setTotal(0);
setHasMore(false);
setOffset(0);
setSelectedId(null);
setMobileDetailOpen(false);
setClearConfirmOpen(false);
setSettingsOpen(false);
props.onNotify('success', t('txt_logs_cleared'));
} catch {
props.onNotify('error', t('txt_clear_logs_failed'));
} finally {
setSettingsSaving(false);
}
}
function selectRetentionMode(nextMode: RetentionMode): void {
setRetentionMode(nextMode);
setSettings((current) => nextMode === 'days'
? { retentionDays: current.retentionDays ?? 90, maxEntries: null }
: { retentionDays: null, maxEntries: current.maxEntries ?? 10_000 });
}
const visibleMetaEntries = selectedLog
? Object.entries(selectedMetadata).filter(([key]) => key !== 'category' && key !== 'level')
: [];
function selectLog(logId: string): void {
setSelectedId(logId);
setSettingsOpen(false);
setClearConfirmOpen(false);
setMobileDetailOpen(true);
}
function handleMobileBack(): void {
if (mobileDetailOpen) {
setMobileDetailOpen(false);
return;
}
props.onMobileBack?.();
}
return (
<div className={`log-center-page ${mobileDetailOpen ? 'log-mobile-detail-open' : ''}`}>
{props.mobileLayout && (
<div className="log-mobile-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={handleMobileBack}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_back')}
</button>
<button
type="button"
className={`btn btn-secondary log-mobile-settings-trigger ${settingsOpen ? 'active' : ''}`}
aria-label={t('txt_log_settings')}
title={t('txt_log_settings')}
aria-expanded={settingsOpen}
onClick={() => {
setSettingsOpen((open) => !open);
setClearConfirmOpen(false);
}}
>
<Settings2 size={18} />
</button>
</div>
)}
<section className="card log-center-toolbar">
<form className="log-filter-form" onSubmit={submitFilters}>
<label className="field log-search-field">
<span>{t('txt_search')}</span>
<div className="input-action-wrap">
<Search size={15} className="input-leading-icon" />
<input
className="input log-search-input"
value={search}
placeholder={t('txt_log_search_placeholder')}
onInput={(event) => setSearch((event.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<label className="field">
<span>{t('txt_log_category')}</span>
<select className="input" value={category} onChange={(event) => setCategory((event.currentTarget as HTMLSelectElement).value as FilterCategory)}>
{CATEGORY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<label className="field">
<span>{t('txt_log_level')}</span>
<select className="input" value={level} onChange={(event) => setLevel((event.currentTarget as HTMLSelectElement).value as FilterLevel)}>
{LEVEL_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<label className="field">
<span>{t('txt_time_range')}</span>
<select className="input" value={range} onChange={(event) => setRange((event.currentTarget as HTMLSelectElement).value as TimeRange)}>
{RANGE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
</label>
<div className="actions log-filter-actions">
<button type="button" className="btn btn-secondary" disabled={loading} onClick={() => void load(offset)}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button
type="button"
className={`btn btn-secondary ${settingsOpen ? 'active' : ''}`}
aria-expanded={settingsOpen}
onClick={() => {
setSettingsOpen((open) => !open);
setClearConfirmOpen(false);
}}
>
<Settings2 size={14} className="btn-icon" />
{t('txt_log_settings')}
</button>
</div>
</form>
{settingsOpen && (
<div className="log-settings-popover">
<div className="section-head log-settings-popover-head">
<h3>{t('txt_log_retention_settings')}</h3>
</div>
<div className="log-settings-mode" role="group" aria-label={t('txt_log_retention_mode')}>
<button
type="button"
className={`log-mode-option ${retentionMode === 'days' ? 'active' : ''}`}
disabled={settingsLoading || settingsSaving}
onClick={() => selectRetentionMode('days')}
>
{t('txt_log_retention_mode_days')}
</button>
<button
type="button"
className={`log-mode-option ${retentionMode === 'entries' ? 'active' : ''}`}
disabled={settingsLoading || settingsSaving}
onClick={() => selectRetentionMode('entries')}
>
{t('txt_log_retention_mode_entries')}
</button>
</div>
{retentionMode === 'days' ? (
<div className="log-settings-retention-block">
<label className="log-settings-label" htmlFor="log-retention-days-select">{t('txt_log_retention_days')}</label>
<div className="log-settings-retention-row">
<select
id="log-retention-days-select"
className="input"
value={String(settings.retentionDays ?? 0)}
disabled={settingsLoading || settingsSaving}
onChange={(event) => setSettings({
retentionDays: Number((event.currentTarget as HTMLSelectElement).value) || null,
maxEntries: null,
})}
>
{RETENTION_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
<button type="button" className="btn btn-primary log-settings-save-btn" disabled={settingsLoading || settingsSaving} onClick={() => void saveSettings()}>
<Save size={14} className="btn-icon" />
{t('txt_save')}
</button>
</div>
</div>
) : (
<div className="log-settings-retention-block">
<label className="log-settings-label" htmlFor="log-max-entries-select">{t('txt_log_max_entries')}</label>
<div className="log-settings-retention-row">
<select
id="log-max-entries-select"
className="input"
value={String(settings.maxEntries ?? 0)}
disabled={settingsLoading || settingsSaving}
onChange={(event) => setSettings({
retentionDays: null,
maxEntries: Number((event.currentTarget as HTMLSelectElement).value) || null,
})}
>
{MAX_ENTRY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
</select>
<button type="button" className="btn btn-primary log-settings-save-btn" disabled={settingsLoading || settingsSaving} onClick={() => void saveSettings()}>
<Save size={14} className="btn-icon" />
{t('txt_save')}
</button>
</div>
</div>
)}
<div className="log-settings-danger">
{clearConfirmOpen ? (
<>
<p>{t('txt_clear_logs_confirm')}</p>
<div className="actions log-clear-confirm-actions">
<button type="button" className="btn btn-secondary" disabled={settingsSaving} onClick={() => setClearConfirmOpen(false)}>
{t('txt_cancel')}
</button>
<button type="button" className="btn btn-danger" disabled={settingsSaving} onClick={() => void clearLogs()}>
<Trash2 size={14} className="btn-icon" />
{t('txt_clear_all_logs')}
</button>
</div>
</>
) : (
<button type="button" className="btn btn-danger ghost-danger" disabled={settingsLoading || settingsSaving} onClick={() => setClearConfirmOpen(true)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_clear_all_logs')}
</button>
)}
</div>
</div>
)}
</section>
<div className="log-center-grid">
<section className="card log-list-panel">
<div className="section-head">
<h3>{t('txt_audit_events')}</h3>
<span className="muted-inline">{page} / {totalPages}</span>
</div>
<div className="log-list">
{logs.map((log) => {
const metadata = parseMetadata(log);
const logCategory = inferCategory(log, metadata);
const logLevel = inferLevel(log, metadata);
return (
<button
key={log.id}
type="button"
className={`log-row ${selectedLog?.id === log.id ? 'active' : ''}`}
onClick={() => selectLog(log.id)}
>
<span className={`log-row-icon log-category-${logCategory}`}>{iconForCategory(logCategory)}</span>
<span className="log-row-main">
<strong>{formatAction(log.action)}</strong>
<small>{formatTime(log.createdAt)}</small>
</span>
<span className={`log-level-pill log-level-${logLevel}`}>{t(`txt_log_level_${logLevel}`)}</span>
</button>
);
})}
{loading && !logs.length && <LoadingState lines={5} compact />}
{!loading && !logs.length && <div className="empty empty-comfortable">{t('txt_no_logs_found')}</div>}
{!!error && <div className="local-error">{error}</div>}
</div>
<div className="actions log-pagination">
<button type="button" className="btn btn-secondary small" disabled={loading || offset <= 0} onClick={() => void load(Math.max(0, offset - PAGE_SIZE))}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
</button>
<span className="log-pagination-count">
{Math.min(offset + logs.length, total)} / {total}
</span>
<button type="button" className="btn btn-secondary small" disabled={loading || !hasMore} onClick={() => void load(offset + PAGE_SIZE)}>
{t('txt_next')}
<ChevronRight size={14} className="btn-icon" />
</button>
</div>
</section>
<section className="card log-detail-panel">
{selectedLog ? (
<>
<div className="section-head log-detail-head">
<div>
<h3>{formatAction(selectedLog.action)}</h3>
<p className="muted-inline">{selectedLog.action}</p>
</div>
<span className={`log-level-pill log-level-${selectedLevel}`}>{t(`txt_log_level_${selectedLevel}`)}</span>
</div>
<div className="log-detail-meta">
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div>
</div>
<div className="log-detail-json">
<h4>{t('txt_metadata')}</h4>
{visibleMetaEntries.length ? (
<dl>
{visibleMetaEntries.map(([key, value]) => (
<div key={key}>
<dt>{formatMetaKey(key)}</dt>
<dd>{formatMetaValueForKey(key, value)}</dd>
</div>
))}
</dl>
) : (
<div className="empty">{t('txt_no_metadata')}</div>
)}
</div>
</>
) : (
<div className="empty empty-comfortable">{t('txt_no_logs_found')}</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { Wifi, WifiOff } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import { t } from '@/lib/i18n';
import {
browserReportsOffline,
getCurrentNetworkStatus,
probeNodeWardenService,
setCurrentNetworkStatus,
subscribeNetworkStatus,
type NetworkStatus,
} from '@/lib/network-status';
const STATUS_CHECK_INTERVAL_MS = 30_000;
function statusLabel(status: NetworkStatus): string {
if (status === 'online') return t('txt_online');
return t('txt_offline');
}
export default function NetworkStatusBadge() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
const label = statusLabel(status);
const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => {
let timer = 0;
const checkService = async () => {
if (browserReportsOffline()) {
setCurrentNetworkStatus('offline');
return;
}
await probeNodeWardenService();
};
const scheduleNextCheck = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
void checkService().finally(scheduleNextCheck);
}, STATUS_CHECK_INTERVAL_MS);
};
const handleOnline = () => {
void checkService();
};
const handleOffline = () => {
setCurrentNetworkStatus('offline');
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') void checkService();
};
const unsubscribe = subscribeNetworkStatus(setStatus);
void checkService().finally(scheduleNextCheck);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
window.addEventListener('focus', handleOnline);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
unsubscribe();
window.clearTimeout(timer);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
window.removeEventListener('focus', handleOnline);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return (
<span
className={`network-status-badge ${status}`}
title={label}
aria-label={label}
aria-live="polite"
>
<Icon size={14} aria-hidden="true" />
<span className="network-status-label">{label}</span>
</span>
);
}
@@ -0,0 +1,112 @@
import { useState } from 'preact/hooks';
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { AuthRequest } from '@/lib/types';
import { t } from '@/lib/i18n';
interface PendingAuthRequestsPanelProps {
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
className?: string;
loadingVariant?: 'placeholder' | 'compact';
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
return Number.isNaN(date.getTime()) ? t('txt_dash') : date.toLocaleString();
}
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onApproveAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onDenyAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
return (
<section className={props.className || 'card settings-module'}>
<div className="settings-module-head">
<h3>{t('txt_pending_device_logins')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={props.pendingAuthRequestsLoading}
onClick={() => void props.onRefreshPendingAuthRequests()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
<div className="account-passkeys-list">
{props.pendingAuthRequestsLoading && props.pendingAuthRequests.length === 0 ? (
props.loadingVariant === 'compact' ? (
<LoadingState lines={2} compact />
) : (
<div className="settings-module-placeholder">
<RefreshCw size={20} />
<span>{t('txt_loading')}</span>
</div>
)
) : props.pendingAuthRequests.length === 0 ? (
<div className="settings-module-placeholder">
<ShieldCheck size={20} />
<span>{t('txt_no_pending_device_logins')}</span>
</div>
) : (
props.pendingAuthRequests.map((authRequest) => (
<div key={authRequest.id} className="account-passkey-row auth-request-row">
<div className="account-passkey-main">
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
<small>{t('txt_created_value', { value: formatDateTime(authRequest.creationDate) })}</small>
</div>
<span className="auth-request-fingerprint-inline">
{authRequest.fingerprintPhrase || t('txt_dash')}
</span>
<div className="actions account-passkey-actions">
<button
type="button"
className="btn btn-primary small"
disabled={!!authRequestSubmittingId}
onClick={() => void approveAuthRequest(authRequest)}
>
<ShieldCheck size={14} className="btn-icon" />
{authRequestSubmittingId === authRequest.id ? t('txt_approving') : t('txt_approve')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={!!authRequestSubmittingId}
onClick={() => void denyAuthRequest(authRequest)}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
</div>
</div>
))
)}
</div>
</section>
);
}
+46 -5
View File
@@ -1,17 +1,24 @@
import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
error: string;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
onRemoveAll: () => void;
@@ -24,6 +31,12 @@ function formatDateTime(value: string | null | undefined): string {
return date.toLocaleString();
}
function isPermanentTrust(value: string | null | undefined): boolean {
if (!value) return false;
const date = new Date(value);
return !Number.isNaN(date.getTime()) && date.getUTCFullYear() >= 2099;
}
function mapDeviceTypeName(type: number): string {
switch (type) {
case 0: return t('txt_android');
@@ -65,6 +78,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return (
<>
<div className="stack">
<PendingAuthRequestsPanel
className="card"
loadingVariant="compact"
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="card">
<div className="section-head">
<div>
@@ -101,7 +124,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button>
</div>
)}
<table className="table">
<table className="table authorized-devices-table">
<colgroup>
<col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" />
<col className="authorized-devices-col-status" />
<col className="authorized-devices-col-date" />
<col className="authorized-devices-col-date" />
<col className="authorized-devices-col-trust" />
<col className="authorized-devices-col-actions" />
</colgroup>
<thead>
<tr>
<th>{t('txt_device')}</th>
@@ -135,14 +167,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{device.trusted ? (
<div className="trusted-cell">
<Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span>
<span>{isPermanentTrust(device.trustedUntil) ? t('txt_permanent_trust') : formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">{t('txt_not_trusted')}</span>
)}
</td>
<td data-label={t('txt_actions')}>
<div className="actions">
<div className="actions authorized-devices-actions">
<button
type="button"
className="btn btn-secondary small"
@@ -152,6 +184,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<ShieldOff size={14} className="btn-icon" />
{t('txt_untrust')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted || !device.trustedUntil || isPermanentTrust(device.trustedUntil)}
onClick={() => props.onTrustPermanently(device)}
>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_trust_permanently')}
</button>
<button
type="button"
className="btn btn-secondary small"
+1 -1
View File
@@ -554,7 +554,7 @@ export default function SendsPage(props: SendsPageProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); setShowPassword(false); }}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
</div>
+193 -14
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps {
profile: Profile;
@@ -18,11 +19,28 @@ interface SettingsPageProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
}
type MasterPasswordPromptAction =
| 'recovery'
| 'apiKey'
| 'rotateApiKey'
| 'createPasskey'
| 'enablePasskeyDirectUnlock'
| 'deletePasskey';
const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
@@ -64,6 +82,13 @@ function clearLegacyTotpSetupSecrets(): void {
}
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return t('txt_dash');
return date.toLocaleString();
}
export default function SettingsPage(props: SettingsPageProps) {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
@@ -74,9 +99,14 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryCode, setRecoveryCode] = useState('');
const [apiKey, setApiKey] = useState('');
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
@@ -97,6 +127,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]);
useEffect(() => {
void refreshAccountPasskeys();
}, [props.profile.id]);
const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret));
@@ -115,14 +149,27 @@ export default function SettingsPage(props: SettingsPageProps) {
}
}
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
async function refreshAccountPasskeys(): Promise<void> {
setAccountPasskeysLoading(true);
try {
setAccountPasskeys(await props.onListAccountPasskeys());
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
} finally {
setAccountPasskeysLoading(false);
}
}
function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void {
setMasterPasswordPrompt(action);
setAccountPasskeyPromptId(credentialId || null);
setMasterPasswordPromptValue('');
}
function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue('');
}
@@ -139,13 +186,25 @@ export default function SettingsPage(props: SettingsPageProps) {
const key = await props.onGetApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} else {
} else if (masterPasswordPrompt === 'rotateApiKey') {
const key = await props.onRotateApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} else if (masterPasswordPrompt === 'createPasskey') {
const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
if (credential) await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'deletePasskey') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onDeleteAccountPasskey(accountPasskeyPromptId, masterPassword);
await refreshAccountPasskeys();
}
setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue('');
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
@@ -159,13 +218,18 @@ export default function SettingsPage(props: SettingsPageProps) {
? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key')
: masterPasswordPrompt === 'createPasskey'
? t('txt_add_account_passkey')
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
? t('txt_enable_passkey_direct_unlock')
: masterPasswordPrompt === 'deletePasskey'
? t('txt_delete_account_passkey')
: t('txt_view_api_key');
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
if (credential.prfStatus === 0) return t('txt_direct_unlock');
if (credential.prfStatus === 1) return t('txt_login_only');
return t('txt_prf_not_supported');
}
async function changeLocale(next: Locale): Promise<void> {
@@ -281,8 +345,15 @@ export default function SettingsPage(props: SettingsPageProps) {
</section>
<section className="card settings-module">
<div className="settings-module-head">
<h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
{totpLocked && (
<span className="totp-status-pill">
<ShieldCheck size={14} aria-hidden="true" />
{t('txt_enabled')}
</span>
)}
</div>
<div className="totp-grid">
<div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" />
@@ -338,8 +409,116 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
</section>
<section className="card settings-module">
<h3>{t('txt_recovery_code_and_api_key')}</h3>
<section className="card settings-module account-passkeys-module">
<div className="settings-module-head">
<h3>{t('txt_account_passkeys')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={accountPasskeysLoading}
title={t('txt_refresh')}
aria-label={t('txt_refresh')}
onClick={() => void refreshAccountPasskeys()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
<div className="field-grid">
<label className="field">
<span>{t('txt_passkey_name')}</span>
<input
className="input"
maxLength={128}
value={accountPasskeyName}
placeholder={t('txt_account_passkey_name_placeholder')}
onInput={(e) => setAccountPasskeyName((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="field account-passkey-mode-field">
<span>{t('txt_account_passkey_mode')}</span>
<label className="account-passkey-toggle">
<input
type="checkbox"
checked={accountPasskeyDirectUnlock}
onInput={(e) => setAccountPasskeyDirectUnlock((e.currentTarget as HTMLInputElement).checked)}
/>
<span>{t('txt_account_passkey_direct_unlock_mode')}</span>
</label>
<div className="field-help">
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
</div>
</div>
</div>
<div className="actions">
<button
type="button"
className="btn btn-primary"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('createPasskey')}
>
<KeyRound size={14} className="btn-icon" />
{t('txt_add_account_passkey')}
</button>
</div>
<div className="account-passkeys-list">
{accountPasskeysLoading ? (
<div className="settings-module-placeholder">
<RefreshCw size={20} />
<span>{t('txt_loading')}</span>
</div>
) : accountPasskeys.length === 0 ? (
<div className="settings-module-placeholder">
<KeyRound size={20} />
<span>{t('txt_no_account_passkeys')}</span>
</div>
) : (
accountPasskeys.map((credential) => (
<div key={credential.id} className="account-passkey-row">
<div className="account-passkey-main">
<strong>{credential.name || t('txt_account_passkey')}</strong>
<small>{t('txt_created_value', { value: formatDateTime(credential.creationDate) })}</small>
</div>
<span className={`account-passkey-status account-passkey-status-${credential.prfStatus}`}>
{accountPasskeyStatusText(credential)}
</span>
<div className="actions account-passkey-actions">
{credential.prfStatus === 1 && (
<button
type="button"
className="btn btn-secondary small"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('enablePasskeyDirectUnlock', credential.id)}
>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_enable_passkey_direct_unlock')}
</button>
)}
<button
type="button"
className="btn btn-danger small"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('deletePasskey', credential.id)}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
</div>
</div>
))
)}
</div>
</section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid">
<div className="sensitive-action">
<div>
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps {
title: string;
eyebrow?: ComponentChildren;
titleAccessory?: ComponentChildren;
children: ComponentChildren;
}
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<div className="standalone-title-row">
<h1 className="standalone-title">{props.title}</h1>
{props.titleAccessory}
</div>
{props.children}
</div>
+7 -130
View File
@@ -1,29 +1,12 @@
import type { JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import {
closestCenter,
DndContext,
type DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState';
import WebsiteIcon from '@/components/vault/WebsiteIcon';
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
@@ -34,7 +17,6 @@ interface TotpCodesPageProps {
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const TOTP_REFRESH_BATCH_SIZE = 16;
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
@@ -44,50 +26,22 @@ function getTotpTimeState(): { windowId: number; remain: number } {
};
}
function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
}
interface SortableTotpRowProps {
interface TotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
onCopy: (value: string) => void;
}
function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
});
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
function TotpRow(props: TotpRowProps) {
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
return (
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...dragButtonAttributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<div className="totp-code-row">
<div className="totp-code-info">
<div className="list-icon-wrap">
<TotpListIcon cipher={props.cipher} />
@@ -135,30 +89,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
});
const listRef = useRef<HTMLDivElement | null>(null);
const hasLoadedTotpItemsRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
async function copyToClipboard(value: string): Promise<void> {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
@@ -169,7 +100,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
[]
);
const baseTotpItems = useMemo(
const totpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
@@ -181,46 +112,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
[props.ciphers, nameCollator]
);
const totpItems = useMemo(() => {
if (!baseTotpItems.length) return [];
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
return [...baseTotpItems].sort((a, b) => {
const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim();
return nameCollator.compare(nameA, nameB);
});
}, [baseTotpItems, orderedIds, nameCollator]);
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
useEffect(() => {
if (!baseTotpItems.length) return;
hasLoadedTotpItemsRef.current = true;
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
setOrderedIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
const next = [...filtered, ...missing];
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
return next;
});
}, [baseTotpItems]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!hasLoadedTotpItemsRef.current) return;
try {
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
} catch {
// ignore storage write failures
}
}, [orderedIds]);
useEffect(() => {
if (!totpItems.length) {
setTotpCodes({});
@@ -307,16 +198,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
return () => observer.disconnect();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId || activeId === overId) return;
const fromIndex = orderedIds.indexOf(activeId);
const toIndex = orderedIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
};
return (
<div className="totp-codes-page">
<div className="card">
@@ -330,18 +211,14 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
>
{!totpItems.length && props.loading && <LoadingState lines={6} />}
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => (
<SortableTotpRow
<TotpRow
key={cipher.id}
cipher={cipher}
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
</SortableContext>
</DndContext>
</div>
</div>
</div>
+106 -10
View File
@@ -9,6 +9,7 @@ import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
cardListSubtitle,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
cipherTypeKey,
@@ -16,13 +17,14 @@ import {
createEmptyDraft,
creationTimeValue,
draftFromCipher,
buildCipherDuplicateSignature,
buildCipherDuplicateSignatures,
firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
@@ -44,6 +46,7 @@ interface VaultPageProps {
onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
onRestore: (ids: string[]) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>;
@@ -77,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -263,6 +267,8 @@ export default function VaultPage(props: VaultPageProps) {
setRepromptApprovedCipherId(null);
setRepromptPassword('');
setRepromptOpen(false);
setShowPassword(false);
setHiddenFieldVisibleMap({});
}, [selectedCipherId]);
useEffect(() => {
@@ -302,9 +308,10 @@ export default function VaultPage(props: VaultPageProps) {
const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher);
const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, {
name,
searchText: `${name}\n${username}\n${uri}`.toLowerCase(),
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(),
firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher),
@@ -333,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>();
const byId = new Map<string, string[]>();
const counts = new Map<string, number>();
for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher);
byId.set(cipher.id, signature);
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signatures);
for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
}
return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]);
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
@@ -353,9 +385,12 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
if (sidebarFilter.kind === 'duplicates') {
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false;
}
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
@@ -399,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind;
}, [sidebarFilter]);
}, [sidebarFilter, duplicateMode]);
useEffect(() => {
setListScrollTop(0);
@@ -414,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
}
}, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (isCreating) return;
if (!filteredCiphers.length) {
@@ -499,6 +539,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
}
if (Number(cipher.type || 1) === 3) {
return cardListSubtitle(cipher);
}
return cipherTypeLabel(Number(cipher.type || 1));
}, [cipherMetaById]);
@@ -516,6 +559,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
setCreateMenuOpen(false);
setSelectedCipherId('');
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
@@ -530,6 +574,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
setIsCreating(false);
setIsEditing(true);
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
@@ -542,6 +587,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setDraft(null);
setIsEditing(false);
setIsCreating(false);
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
@@ -704,6 +751,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -717,6 +766,22 @@ const folderName = useCallback((id: string | null | undefined): string => {
setPendingDelete(null);
cancelEdit();
if (isMobileLayout) setMobilePanel('list');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function handleRestoreSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onRestore([cipher.id]);
if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -736,6 +801,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
}
setSelectedMap({});
setBulkDeleteOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -752,6 +819,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkMove(ids, folderId);
setSelectedMap({});
setMoveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -761,6 +830,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setBusy(true);
try {
await props.onRefresh();
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -795,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false);
setNewFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -809,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' });
}
setPendingDeleteFolder(null);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -826,6 +901,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null);
setRenameFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -840,6 +917,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try {
await props.onBulkRestore(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -854,6 +933,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -868,6 +949,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
delete next[cipher.id];
return next;
});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -883,6 +966,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkArchive(ids);
setSelectedMap({});
setBulkArchiveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -897,6 +982,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try {
await props.onBulkUnarchive(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -911,6 +998,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' });
}
setDeleteAllFoldersOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
@@ -930,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
@@ -941,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -971,6 +1061,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
@@ -1026,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
searchInput={searchInput}
sortMode={sortMode}
sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount}
totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange}
selectedCipherId={selectedCipherId}
selectedMap={selectedMap}
@@ -1046,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
@@ -1136,6 +1231,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit}
onDelete={setPendingDelete}
onRestore={(cipher) => void handleRestoreSelected(cipher)}
onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/>
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type {
BackupDestinationRecord,
RemoteBackupBrowserResponse,
S3BackupAddressingStyle,
S3BackupDestination,
WebDavBackupDestination,
} from '@/lib/api/backup';
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{props.selectedDestination.type === 's3' ? (
<div className="field-grid">
<label className="field field-span-2">
<label className="field">
<span>{t('txt_backup_s3_endpoint')}</span>
<input
className="input"
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_s3_addressing_style')}</span>
<select
className="input"
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as S3BackupDestination),
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
},
}))}
>
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
</select>
</label>
<label className="field">
<span>{t('txt_backup_s3_bucket')}</span>
<input

Some files were not shown because too many files have changed in this diff Show More