Compare commits

...
132 Commits
Author SHA1 Message Date
dependabot[bot]andShuai 8d292ca7b8 chore(deps): bump the npm-minor-and-patch group across 1 directory with 15 updates
Bumps the npm-minor-and-patch group with 15 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@noble/hashes](https://github.com/paulmillr/noble-hashes) | `2.0.1` | `2.2.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.21` | `5.101.2` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.22` | `2.8.26` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [preact](https://github.com/preactjs/preact) | `10.28.4` | `10.29.3` |
| [wouter](https://github.com/molefrog/wouter) | `3.9.0` | `3.10.0` |
| [@cloudflare/workers-types](https://github.com/cloudflare/workerd) | `4.20260609.1` | `4.20260630.1` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.3` | `2.10.5` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.21` | `10.5.2` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.0.5` | `1.3.2` |
| [postcss](https://github.com/postcss/postcss) | `8.5.15` | `8.5.16` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.17` | `3.4.19` |
| [tsx](https://github.com/privatenumber/tsx) | `4.21.0` | `4.22.4` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.98.0` | `4.105.0` |



Updates `@noble/hashes` from 2.0.1 to 2.2.0
- [Release notes](https://github.com/paulmillr/noble-hashes/releases)
- [Commits](https://github.com/paulmillr/noble-hashes/compare/2.0.1...2.2.0)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.2
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

Updates `@tanstack/react-query` from 5.90.21 to 5.101.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.2/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.22 to 2.8.26
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.22...v2.8.26)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `preact` from 10.28.4 to 10.29.3
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.4...10.29.3)

Updates `wouter` from 3.9.0 to 3.10.0
- [Release notes](https://github.com/molefrog/wouter/releases)
- [Commits](https://github.com/molefrog/wouter/commits)

Updates `@cloudflare/workers-types` from 4.20260609.1 to 4.20260630.1
- [Release notes](https://github.com/cloudflare/workerd/releases)
- [Changelog](https://github.com/cloudflare/workerd/blob/main/RELEASE.md)
- [Commits](https://github.com/cloudflare/workerd/commits)

Updates `@preact/preset-vite` from 2.10.3 to 2.10.5
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Commits](https://github.com/preactjs/preset-vite/compare/2.10.3...2.10.5)

Updates `autoprefixer` from 10.4.21 to 10.5.2
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.21...10.5.2)

Updates `opencc-js` from 1.0.5 to 1.3.2
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.0.5...v1.3.2)

Updates `postcss` from 8.5.15 to 8.5.16
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.16)

Updates `tailwindcss` from 3.4.17 to 3.4.19
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v3.4.19/packages/tailwindcss)

Updates `tsx` from 4.21.0 to 4.22.4
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.4)

Updates `wrangler` from 4.98.0 to 4.105.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.105.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/workers-types"
  dependency-version: 4.20260630.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@noble/hashes"
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.3.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 3.4.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.22.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wouter
  dependency-version: 3.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.105.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 13:55:20 +08:00
shuaiplus 5dd9dff045 ci: update actions/checkout and other actions to specific versions 2026-06-30 13:49:33 +08:00
shuaiplus 709a8c1768 Merge pull request #279 from dependabot/github_actions/github-actions-db669df06a 2026-06-30 13:44:53 +08:00
shuaiplus e2c3516ce9 Merge pull request #284 from dependabot/npm_and_yarn/lucide-preact-1.22.0 2026-06-30 13:43:17 +08:00
shuaiplus 55b5c57f9e Merge pull request #283 from dependabot/npm_and_yarn/typescript-6.0.3 2026-06-30 13:43:07 +08:00
shuaiplus b6fb62603b Merge pull request #282 from dependabot/npm_and_yarn/types/node-26.0.1 2026-06-30 13:42:57 +08:00
shuaiplus 35071c2719 ci: limit risky dependabot updates 2026-06-30 13:36:00 +08:00
dependabot[bot]andGitHub 5bd7dab277 chore(deps): bump lucide-preact from 0.575.0 to 1.22.0
Bumps [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) from 0.575.0 to 1.22.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.22.0/packages/lucide-preact)

---
updated-dependencies:
- dependency-name: lucide-preact
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:03 +00:00
dependabot[bot]andGitHub 99f2d7f444 chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:02 +00:00
dependabot[bot]andGitHub fb9a2aeda1 chore(deps-dev): bump @types/node from 25.2.3 to 26.0.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.2.3 to 26.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:27:57 +00:00
dependabot[bot]andGitHub c87e6ac984 chore(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-node](https://github.com/actions/setup-node) and [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `peter-evans/create-pull-request` from 6 to 8
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:26:28 +00:00
shuaiplus a6f1c6dea2 Merge branch 'beta' 2026-06-30 13:25:22 +08:00
shuaiplus 49c872a8ec ci: fix sync workflow shell lint 2026-06-30 13:18:02 +08:00
shuaiplus 78af1f9bdd ci: skip scorecard outside main 2026-06-30 13:14:23 +08:00
shuaiplus 32b3d2ade1 chore: override ws vulnerability 2026-06-30 13:11:07 +08:00
shuaiplus 64f26e76f6 chore: add security automation workflows 2026-06-30 13:05:04 +08:00
rootphantomerandShuai 68c42a0330 fix: preserve multiline values (e.g. SSH private keys) during CSV import
parseBitwardenCsvFieldLines previously discarded any field line that did not
contain the ': ' delimiter, truncating multiline values like OpenSSH private
keys to only their first line.

Replace the map+filter pipeline with a reduce that accumulates continuation
lines (lines without ': ') into the previous entry's value, joined by '\n'.
This preserves the full private key content through a CSV round-trip.

Fixes: CSV export to import of SSH key items where the private key body was
silently dropped.
2026-06-30 11:44:03 +08:00
shuaiplus 0d1bb196e2 feat: add functionality to delete invalid invites and update related components 2026-06-29 12:12:13 +08:00
shuaiplus e31f82c0d6 feat: update project wiki link and remove obsolete security scripts and workflows 2026-06-29 11:57:53 +08:00
shuaiplus f82dcc3c17 feat: rename revokeInvite to deleteInvite and update related functionality 2026-06-29 11:47:05 +08:00
shuaiplus 4378e1b430 feat: add pendingAuthRequestsRefreshing state to improve loading feedback in auth request components 2026-06-29 11:29:35 +08:00
shuaiplus 5eeaf4e32e feat: enhance Bitwarden CSV parsing with custom field handling and metadata restoration 2026-06-29 11:10:41 +08:00
shuaiplus 82f968e51f feat: add validFolderIds support for cipher responses and update folder handling in storage 2026-06-28 19:43:27 +08:00
shuaiplus a5ad16ac27 feat: add device selection and removal functionality in SecurityDevicesPage 2026-06-28 15:31:29 +08:00
shuaiplus 6a1a8357bf feat: refactor PRF extension handling in credential options 2026-06-28 14:13:06 +08:00
shuaiplus 31cfd19b6b feat: add support for excluding PRF extensions in credential options 2026-06-28 14:02:51 +08:00
shuaiplus 4cd9ad00d2 Add backup-related error messages and improve UI styles
- Updated English, Spanish, Russian, Simplified Chinese, and Traditional Chinese locale files to include new error messages related to backup and restore processes.
- Added prefix and suffix strings for the "cached empty" message to enhance clarity in user prompts.
- Enhanced the management CSS with new styles for the backup browser refresh prompt to improve layout and user experience.
2026-06-27 12:38:41 +08:00
shuaiplus 31dcc76ee2 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-06-26 20:58:22 +08:00
soncmsandShuai bf6ac7b405 Enable WebAuthn related origins support 2026-06-26 20:51:33 +08:00
shuaiplus 1bfb9a647d feat: refine app-shell styles for improved layout and dark mode consistency 2026-06-26 19:12:20 +08:00
shuaiplus e9272ec29a feat: enhance backup import functionality with locking mechanism and checksum support 2026-06-26 18:45:23 +08:00
shuaiplus 8942e5bd49 feat: add support for PRF extension request based on browser compatibility 2026-06-26 11:58:49 +08:00
shuaiplus d722815999 feat: add fullscreen layout support with toggle and localization updates 2026-06-26 11:26:02 +08:00
shuaiplus ff85698edb feat: add Tigris backup provider support with recommendations and localization updates 2026-06-25 21:11:57 +08:00
shuaiplus c3dc53bac1 feat: add Cloudflare R2 support with detailed backup recommendations and localization updates 2026-06-25 19:45:09 +08:00
shuaiplus 1acc31eda0 feat: add Backblaze B2 support with recommendations and styling updates 2026-06-25 18:42:34 +08:00
shuaiplusandClaude c694f1bfce refactor: consolidate security devices UI and remove pending auth requests from settings
- Merge device management and authorized devices sections into a single card in SecurityDevicesPage
- Remove PendingAuthRequestsPanel from SettingsPage and its related props
- Clean up unused auth request prop drilling in AppMainRoutes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 01:49:17 +08:00
shuaiplus bf51309fbb fix: update version to v1.7.1 2026-06-24 01:55:09 +08:00
shuaiplusandShuai 23b23f39b9 fix: require reauthentication for auth request approval 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 0daad46591 chore: add package overrides for undici, @babel/core, and esbuild 2026-06-24 01:44:50 +08:00
shuaiplusandShuai a2a8f1c7b6 fix:Harden authentication and sensitive file handling 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 850fe0f044 fix: two-phase invite consumption to prevent registration race condition 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 7279668955 fix: address security issue 2026-06-24 01:44:50 +08:00
shuaiplus 5048cc0720 chore: bump version to 1.7.0 2026-06-23 01:36:38 +08:00
ShuaiandGitHub 3f785febc8 Add SECURITY.md
Add security reporting policy and vulnerability disclosure guidance.
2026-06-23 00:07:46 +08:00
shuaiplus 907126d152 fix: refine login success toast handling 2026-06-22 23:15:11 +08:00
shuaiplus c1f57957c0 Remove vault toolbar switch animation 2026-06-22 22:39:48 +08:00
shuaiplus cd2ec8240b Show mobile sort button label 2026-06-22 22:32:58 +08:00
shuaiplus 16bde22604 Unify mobile topbar controls 2026-06-22 22:30:29 +08:00
shuaiplus 4900de0444 Refresh auth requests from realtime notifications 2026-06-22 22:09:54 +08:00
shuaiplus 79ed7c9f85 Add Bitwarden push relay support 2026-06-22 22:09:38 +08:00
shuaiplus 9a21504f40 Fix realtime sync notifications 2026-06-22 16:46:55 +08:00
shuaiplus 045b23fc47 Align web vault updates with resource sync 2026-06-21 18:16:44 +08:00
shuaiplus 42b765b113 Use resource sync notifications in the web client 2026-06-21 16:14:20 +08:00
shuaiplus f9fe53285f Preserve stored cipher permission flags in responses 2026-06-21 15:46:37 +08:00
shuaiplus 46ba8b9950 Emit cipher update notifications for attachment changes 2026-06-21 15:42:09 +08:00
shuaiplus f096681a2b Align public send access notifications with Bitwarden 2026-06-21 15:38:51 +08:00
shuaiplus fe0c66c561 Add official Bitwarden resource sync notifications 2026-06-21 15:14:42 +08:00
shuaiplus add921b3b3 Improve Bitwarden compatibility across account, sync, attachment, and send flows 2026-06-21 15:02:41 +08:00
shuaiplus f1b716fb31 chore: update .gitignore file 2026-06-20 00:01:36 +08:00
shuaiplus 8f2704fd41 feat: update toast close button with SVG icon and improve styling 2026-06-16 21:48:48 +08:00
shuaiplus 7e0406f751 feat: enhance mobile vault filter UI and improve styling for better usability 2026-06-16 21:17:43 +08:00
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
shuaiplusandShuai a06cb0ed71 fix: serialize Bitwarden CSV login URIs 2026-06-13 16:38:25 +08:00
DiaMeowwandShuai b0242265f4 fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
2026-06-13 16:38:25 +08:00
Wankko ReeandShuai 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
shuaiplusandClaude Sonnet 4.6 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
rootphantomerandshuaiplus 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
52assertandshuaiplus 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
ageskyandShuai 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
161 changed files with 26846 additions and 2917 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Project Wiki/ 项目文档 - name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki url: https://nodewarden.app
about: | about: |
Please check the documentation for common questions and troubleshooting steps. Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。 请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
groups:
npm-minor-and-patch:
update-types:
- "minor"
- "patch"
ignore:
- dependency-name: "tailwindcss"
update-types:
- "version-update:semver-major"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:10"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 0
groups:
github-actions:
patterns:
- "*"
-467
View File
@@ -1,467 +0,0 @@
const fs = require('fs');
const path = require('path');
/**
* Security Report Generator (Node.js)
* Better, faster, and more maintainable than Bash.
*/
class SecurityReport {
constructor() {
this.results = {
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
coverage: { actions: 0, js: 0, ts: 0 },
artifactUris: []
};
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
this.runId = process.env.GITHUB_RUN_ID || '0';
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
this.locales = {
zh: {
filename: 'security-report-cn.md',
switcher: '[English](security-report.md) | 中文',
title: '🛡️ 安全审计与透明度报告',
grade: '安全评级',
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
auditTime: '📅 审计时间',
runId: '📝 运行 ID',
env: '🛠️ 环境',
dashboard: '📉 实时安全仪表盘',
tool: '工具',
status: '状态',
findings: '发现项',
leaks: '泄露',
vulns: '漏洞',
alerts: '告警',
coverageTitle: '🔍 扫描覆盖范围',
module: '模块',
auditedFiles: '已审计文件',
coverage: '覆盖率',
detailedFindings: '🔍 详细发现项',
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
snykTitle: '📦 第三方依赖',
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
package: '软件包',
severity: '严重程度',
description: '描述',
fixPlan: '修复方案',
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
codeqlSummary: '#### 摘要',
rulesChecked: '已检查规则',
totalAlerts: '告警总数',
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
ruleId: '规则 ID',
level: '级别',
location: '位置',
auditedList: '📂 已审计文件列表',
guideTitle: '⚠️ 操作指南',
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
},
en: {
filename: 'security-report.md',
switcher: 'English | [中文](security-report-cn.md)',
title: '🛡️ Security Audit & Transparency Report',
grade: 'Security Grade',
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
auditTime: '📅 Audit Time',
runId: '📝 Run ID',
env: '🛠️ Environment',
dashboard: '📉 Real-time Security Dashboard',
tool: 'Tool',
status: 'Status',
findings: 'Findings',
leaks: 'Leaks',
vulns: 'Vulns',
alerts: 'Alerts',
coverageTitle: '🔍 Scan Coverage',
module: 'Module',
auditedFiles: 'Audited Files',
coverage: 'Coverage',
detailedFindings: '🔍 Detailed Findings',
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
snykTitle: '📦 Third-party Dependencies',
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
package: 'Package',
severity: 'Severity',
description: 'Description',
fixPlan: 'Fix Plan',
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
codeqlSummary: '#### Summary',
rulesChecked: 'Rules Checked',
totalAlerts: 'Total Alerts',
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
ruleId: 'Rule ID',
level: 'Level',
location: 'Location',
auditedList: '📂 Audited File List',
guideTitle: '⚠️ Action Guide',
guideDesc: 'If you see a **FAIL** status or serious code issues:',
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
trivySafe: '✅ **SAFE**: No container configuration defects found.'
}
};
}
// --- Data Parsers ---
async parseCodeQL() {
const sarifPath = 'sarif-results';
if (!fs.existsSync(sarifPath)) return;
const files = this.globFiles(sarifPath, '.sarif');
let totalAlerts = 0;
let rulesSet = new Set();
let findings = [];
let artifactUris = new Set();
for (const file of files) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const run of data.runs || []) {
// Collect Rules
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
(run.tool.extensions || []).forEach(ext => {
(ext.rules || []).forEach(r => rulesSet.add(r.id));
});
// Collect Results
for (const res of run.results || []) {
totalAlerts++;
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
findings.push({
id: res.ruleId,
level: res.level || 'warning',
path: loc.artifactLocation?.uri || 'Global',
line: loc.region?.startLine || '-',
message: res.message?.text || 'No description'
});
}
// Track Coverage (Deduplicated)
(run.artifacts || []).forEach(art => {
const uri = art.location?.uri || '';
if (uri) artifactUris.add(uri);
});
}
}
this.results.artifactUris = Array.from(artifactUris).sort();
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
this.results.codeql.alertCount = totalAlerts;
this.results.codeql.rulesCount = rulesSet.size;
this.results.codeql.findings = findings;
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
}
async parseSnyk() {
const jsonPath = 'snyk_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const projects = Array.isArray(data) ? data : [data];
let vulnTotal = 0;
let findings = [];
for (const proj of projects) {
const vulns = proj.vulnerabilities || [];
vulnTotal += vulns.length;
vulns.forEach(v => {
findings.push({
pkg: `${v.packageName}@${v.version}`,
severity: v.severity,
title: v.title,
url: v.url,
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
});
});
}
this.results.snyk.vulnCount = vulnTotal;
this.results.snyk.findings = findings;
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
} catch (e) {
console.error('Error parsing Snyk JSON:', e.message);
}
}
async parseGitleaks() {
const files = this.globFiles('.', 'results.sarif');
if (files.length === 0) return;
try {
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
let leaks = 0;
let findings = [];
for (const run of data.runs || []) {
for (const res of run.results || []) {
leaks++;
findings.push({
id: res.ruleId,
message: res.message.text,
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
});
}
}
this.results.gitleaks.leaksCount = leaks;
this.results.gitleaks.findings = findings;
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
} catch (e) {
console.error('Error parsing Gitleaks SARIF:', e.message);
}
}
async parseTrivy() {
const jsonPath = 'trivy_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let misconfigs = 0;
let findings = [];
(data.Results || []).forEach(res => {
(res.Misconfigurations || []).forEach(m => {
misconfigs++;
findings.push({
id: m.ID,
severity: m.Severity,
title: m.Title,
message: m.Message,
status: m.Status,
target: res.Target
});
});
});
this.results.trivy.misconfigCount = misconfigs;
this.results.trivy.findings = findings;
if (misconfigs > 0) this.results.trivy.status = 'WARN';
} catch (e) {
console.error('Error parsing Trivy JSON:', e.message);
}
}
generateTable(type, t) {
let files = [];
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
if (files.length === 0) return `> ${t.noFiles}\n`;
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
files.forEach(f => {
const filename = path.basename(f);
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
});
return table;
}
// --- Renderers ---
generateMarkdown(localeKey) {
const { codeql, snyk, gitleaks, coverage } = this.results;
const t = this.locales[localeKey];
// Calculate Grade
let grade = 'A+';
let gradeColor = 'success';
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
const badge = (label, value, color) => `![${label}](https://img.shields.io/badge/${label.replace(/ /g, '_')}-${value}-${color}?style=for-the-badge)`;
let md = `# ${t.title}\n\n`;
md += `${t.switcher}\n\n`;
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
md += `${t.important}\n\n`;
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
md += `---\n\n## ${t.dashboard}\n\n`;
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
md += `---\n\n## ${t.coverageTitle}\n\n`;
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
md += `| :--- | :---: | :---: |\n`;
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
md += `---\n\n## ${t.detailedFindings}\n\n`;
// Gitleaks Section
md += `### ${t.gitleaksTitle}\n`;
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
if (gitleaks.findings.length > 0) {
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :--- | :--- |\n`;
gitleaks.findings.forEach(f => {
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
});
} else {
md += `${t.gitleaksSafe}\n`;
}
// Trivy Section
md += `\n### ${t.trivyTitle}\n`;
md += `${t.trivyDesc}\n\n`;
if (this.results.trivy.findings.length > 0) {
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
this.results.trivy.findings.forEach(f => {
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
});
} else {
md += `${t.trivySafe}\n`;
}
// Snyk Section
md += `\n### ${t.snykTitle}\n`;
if (snyk.findings.length > 0) {
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
snyk.findings.forEach(f => {
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
});
} else {
md += `${t.snykSafe}\n`;
}
// CodeQL Section
md += `\n### ${t.codeqlTitle}\n`;
if (codeql.findings.length > 0) {
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
codeql.findings.forEach(f => {
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
const prefix = f.id.split('/')[0];
const langMap = {
'js': 'javascript',
'actions': 'github-actions',
'cpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'java': 'java',
'py': 'python',
'rb': 'ruby',
'swift': 'swift'
};
const langPath = langMap[prefix] || 'javascript';
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
});
} else {
md += `${t.codeqlSafe}\n`;
}
// Audited Files List
md += `\n### ${t.auditedList}\n`;
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
md += this.generateTable('actions', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
md += this.generateTable('js', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
md += this.generateTable('ts', t);
md += `\n</details>\n\n`;
// Action Guide
md += `--- \n\n## ${t.guideTitle}\n\n`;
md += `${t.guideDesc}\n`;
md += `${t.guideStep1}\n`;
md += `${t.guideStep2}\n`;
md += `${t.guideStep3}\n\n`;
md += `--- \n\n${t.footer}`;
return md;
}
// --- Helpers ---
getBadge(status) {
if (status === 'PASS') return '![Pass](https://img.shields.io/badge/Status-PASS-success?style=for-the-badge)';
if (status === 'WARN' || status === 'INFO') return '![Warning](https://img.shields.io/badge/Status-NOTICE-orange?style=for-the-badge)';
return '![Fail](https://img.shields.io/badge/Status-FAIL-red?style=for-the-badge)';
}
globFiles(dir, ext) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
results = results.concat(this.globFiles(fullPath, ext));
} else if (file.endsWith(ext)) {
results.push(fullPath);
}
}
return results;
}
async run() {
console.log('--- Security Report Generation Started ---');
await this.parseCodeQL();
await this.parseSnyk();
await this.parseGitleaks();
await this.parseTrivy();
for (const localeKey of Object.keys(this.locales)) {
const locale = this.locales[localeKey];
const markdown = this.generateMarkdown(localeKey);
fs.writeFileSync(locale.filename, markdown);
console.log(`Report generated successfully at ${locale.filename}`);
}
}
}
new SecurityReport().run().catch(err => {
console.error('Report generation failed:', err);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
name: "CodeQL Advanced"
on:
push:
branches:
- "**"
permissions:
contents: read
actions: read
security-events: write
packages: read
jobs:
analyze:
name: CodeQL Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
category: "/language:${{ matrix.language }}"
+200
View File
@@ -0,0 +1,200 @@
name: "Extra Security Scan"
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_SUMMARY: "true"
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
osv:
name: OSV Dependency Scan
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
permissions:
contents: read
actions: read
security-events: write
with:
scan-args: |-
--recursive
./
upload-sarif: true
fail-on-vuln: true
pnpm-audit:
name: pnpm audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Run pnpm audit
shell: bash
run: |
if [ ! -f pnpm-lock.yaml ]; then
echo "pnpm-lock.yaml not found, skip pnpm audit."
exit 0
fi
corepack enable
corepack prepare pnpm@10 --activate
pnpm audit --audit-level=high
semgrep:
name: Semgrep CE Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run Semgrep CE
shell: bash
run: |
docker run --rm \
-v "${PWD}:/src" \
-w /src \
semgrep/semgrep:latest \
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
if [ ! -f semgrep.sarif ]; then
cat > semgrep.sarif <<'EOF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "Semgrep",
"informationUri": "https://semgrep.dev",
"rules": []
}
},
"results": []
}
]
}
EOF
fi
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: semgrep.sarif
category: semgrep
actionlint:
name: GitHub Actions Syntax Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run actionlint
shell: bash
run: |
docker run --rm \
-v "${PWD}:/repo" \
-w /repo \
rhysd/actionlint:latest
zizmor:
name: GitHub Actions Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
with:
persona: auditor
min-severity: medium
min-confidence: medium
scorecard:
name: OpenSSF Scorecard
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: false
- name: Upload Scorecard SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: scorecard.sarif
category: openssf-scorecard
-142
View File
@@ -1,142 +0,0 @@
name: Security Scan
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Initialize CodeQL
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript, actions
build-mode: none
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/analyze@v4
with:
upload: true
output: sarif-results
- name: Install Gitleaks
if: env.ACT != 'true'
continue-on-error: true
run: |
GITLEAKS_VERSION="8.28.0"
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
- name: Secret Detection
if: env.ACT != 'true'
continue-on-error: true
run: |
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
- name: Install Project Dependencies
if: env.SECURITY_SNYK_TOKEN != ''
env:
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
run: |
echo "Preparing dependency lock files for security scanning..."
if [ -z "$SECURITY_PACKAGE" ]; then
echo "SECURITY_PACKAGE is empty, installing in root..."
npm install --package-lock-only
else
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
# Split by comma and install
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
echo "Installing in "$pkg"..."
npm install --prefix "$pkg" --package-lock-only
else
echo "Warning: Directory $pkg not found, skipping."
fi
done
fi
- name: Dependency Scan
id: snyk
if: env.SECURITY_SNYK_TOKEN != ''
continue-on-error: true
run: |
npm install -g snyk
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
- name: Check for Dockerfile
id: check_docker
run: |
if [ -f "Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Container Security Scan (Trivy)
if: steps.check_docker.outputs.exists == 'true'
continue-on-error: true
run: |
VERSION="0.56.1"
echo "Installing Trivy $VERSION..."
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
- name: Generate Security Report
run: |
# Gitleaks typically produces results.sarif if configured or by default in some versions
# We'll ensure it exists for our reporter
node .github/scripts/security.cjs
# Also append to step summary for immediate visibility in GHA UI
cat security-report.md >> $GITHUB_STEP_SUMMARY
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
- name: Upload Gitleaks Results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: results.sarif
category: gitleaks
- name: Upload Security Report Artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: security-report
if-no-files-found: ignore
path: |
security-report.md
security-report-cn.md
snyk_result.txt
snyk_result.json
trivy_result.json
results.sarif
sarif-results/*.sarif
+3 -3
View File
@@ -19,9 +19,9 @@ jobs:
sync-global-domains: sync-global-domains:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with: with:
node-version: 22 node-version: 22
@@ -32,7 +32,7 @@ jobs:
run: git diff --exit-code -- src/static/global_domains.custom.json run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request - name: Create pull request
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with: with:
branch: chore/sync-bitwarden-global-domains branch: chore/sync-bitwarden-global-domains
delete-branch: true delete-branch: true
+26 -18
View File
@@ -17,7 +17,7 @@ jobs:
sync: sync:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -49,9 +49,11 @@ jobs:
echo "Tag '$LATEST_TAG' not found after fetch." echo "Tag '$LATEST_TAG' not found after fetch."
exit 1 exit 1
fi fi
echo "mode=auto" >> $GITHUB_OUTPUT {
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT echo "mode=auto"
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT echo "latest_tag=$LATEST_TAG"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)" echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then elif [ -n "$MANUAL_INPUT" ]; then
@@ -61,15 +63,19 @@ jobs:
echo "Cannot resolve '$MANUAL_INPUT' to a commit." echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1 exit 1
fi fi
echo "mode=manual" >> $GITHUB_OUTPUT {
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)" echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else else
# Manual mode, blank input: use latest commit on upstream/main # Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main) TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT {
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — latest commit: $TARGET_SHA" echo "Manual mode — latest commit: $TARGET_SHA"
fi fi
@@ -84,19 +90,19 @@ jobs:
CURRENT_SHA=$(git rev-parse HEAD) CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping." echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT echo "needs_update=false" >> "$GITHUB_OUTPUT"
else else
echo "Switching to $TARGET_SHA" echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi fi
else else
# Auto: skip if target is already in ancestry # Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping." echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT echo "needs_update=false" >> "$GITHUB_OUTPUT"
else else
echo "Update needed — target: $TARGET_SHA" echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi fi
fi fi
@@ -117,7 +123,7 @@ jobs:
if: steps.check.outputs.needs_update == 'true' if: steps.check.outputs.needs_update == 'true'
run: | run: |
# Always keep our own workflow file, never let upstream overwrite it # Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true git checkout 'HEAD@{1}' -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync" git commit -m "chore: restore sync-upstream workflow after sync"
fi fi
@@ -134,10 +140,12 @@ jobs:
- name: Summary - name: Summary
run: | run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY {
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY echo "### Synced successfully"
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY echo "- **Mode:** ${{ steps.resolve.outputs.mode }}"
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}"
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`"
} >> "$GITHUB_STEP_SUMMARY"
else else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY echo "### Nothing to update" >> "$GITHUB_STEP_SUMMARY"
fi fi
+17 -1
View File
@@ -18,6 +18,7 @@ build/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
docs/
# OS # OS
.DS_Store .DS_Store
@@ -26,7 +27,7 @@ Thumbs.db
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
.vite-tailwind.err
# Environment # Environment
.env .env
.env.local .env.local
@@ -40,6 +41,7 @@ npm-debug.log*
tmp/ tmp/
.tmp/ .tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/ nodewarden.wiki/
wiki/ wiki/
@@ -47,3 +49,17 @@ AGENTS.md
settings.json settings.json
.claude/ .claude/
NodeWarden-compat/ NodeWarden-compat/
.codex-upstream/
.codex-upstream/bitwarden-server/
.codex-upstream/bitwarden-clients/
.codex-upstream/bitwarden-web/
.codex-upstream/bitwarden-browser/
.reasonix/
.upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
.mcp.json
opencode.jsonc
.cursor/
+40 -15
View File
@@ -34,16 +34,19 @@
| 能力 | Bitwarden | NodeWarden | 说明 | | 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---| |---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** | | 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | | **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** | | **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** | | 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 | | TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 | | 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 | | 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP | | 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
--- ---
@@ -58,16 +61,21 @@
--- ---
## 网页部署 ## 可视化快速部署
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] > [!TIP]
> 默认R2与可选KV的区别: > 默认R2与可选KV的区别:
@@ -105,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` - ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/` - 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传 - 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
+31 -11
View File
@@ -37,16 +37,19 @@
| Capability | Bitwarden | NodeWarden | Notes | | Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** | | Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | | **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | | 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** | | Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support | | TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration | | Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented | | 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 | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
--- ---
@@ -99,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: - When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json` - the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/` - actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time - later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore: - During remote restore:
- required attachment files are loaded from `attachments/` on demand - required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely - missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database - skipped attachments do not leave broken rows in the restored database
--- ---
+76
View File
@@ -0,0 +1,76 @@
# Security Policy
## Reporting a Vulnerability
Thank you for helping keep NodeWarden safe.
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
Use GitHub Private Vulnerability Reporting instead:
1. Open the NodeWarden repository on GitHub.
2. Go to **Security and quality**.
3. Click **Report a vulnerability**.
4. Submit the report privately.
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
## What to Include
Please include as much detail as possible:
* A clear description of the vulnerability.
* Steps to reproduce.
* Affected version, commit, or deployment method.
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
* Expected behavior and actual behavior.
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
## Scope
Security reports are welcome for issues affecting NodeWarden itself, including:
* Authentication and session handling.
* User authorization and cross-user access.
* Vault data, cipher sync, attachments, and Send.
* Import, export, backup, and restore.
* Passkey, WebAuthn, and two-factor authentication.
* Secret handling and provider credentials.
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
## Out of Scope
The following are usually out of scope:
* Issues only affecting third-party services or user infrastructure.
* Misconfigured personal deployments not caused by NodeWarden defaults.
* Social engineering or phishing.
* Denial-of-service testing.
* Scanner-only reports without a practical exploit path.
* Reports that only mention outdated dependencies without showing real impact.
## Response
NodeWarden is maintained on a best-effort basis.
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
## Supported Versions
Security fixes are generally provided for the latest release and the latest code on the default branch.
| Version | Supported |
| -------------- | ---------------------- |
| Latest release | Yes |
| `main` branch | Yes |
| Older releases | Best effort |
| Modified forks | Not directly supported |
## Rewards
NodeWarden does not currently operate a paid bug bounty program.
+82
View File
@@ -154,6 +154,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
actor_user_id TEXT, actor_user_id TEXT,
action TEXT NOT NULL, action TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
target_type TEXT, target_type TEXT,
target_id TEXT, target_id TEXT,
metadata TEXT, metadata TEXT,
@@ -162,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_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at); CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -172,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
push_uuid TEXT,
push_token TEXT,
banned INTEGER NOT NULL DEFAULT 0, banned INTEGER NOT NULL DEFAULT 0,
banned_at TEXT, banned_at TEXT,
device_note TEXT, device_note TEXT,
@@ -183,6 +189,34 @@ CREATE TABLE IF NOT EXISTS devices (
); );
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
CREATE TABLE IF NOT EXISTS auth_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
organization_id TEXT,
type INTEGER NOT NULL,
request_device_identifier TEXT NOT NULL,
request_device_type INTEGER NOT NULL,
request_ip_address TEXT,
request_country_name TEXT,
response_device_identifier TEXT,
access_code TEXT NOT NULL,
public_key TEXT NOT NULL,
key TEXT,
master_password_hash TEXT,
approved INTEGER,
creation_date TEXT NOT NULL,
response_date TEXT,
authentication_date TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
ON auth_requests(user_id, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
ON auth_requests(user_id, request_device_identifier, creation_date);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
@@ -194,6 +228,54 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
ON webauthn_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
ON webauthn_credentials(user_id, updated_at);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
challenge_hash TEXT PRIMARY KEY,
scope TEXT NOT NULL,
user_id TEXT,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
ON webauthn_challenges(user_id, scope);
-- Rate limiting -- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip ( CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY, ip TEXT PRIMARY KEY,
+1161 -634
View File
File diff suppressed because it is too large Load Diff
+29 -22
View File
@@ -1,22 +1,22 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.5.2", "version": "1.7.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"main": "src/index.ts", "main": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run build && wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml", "dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174", "dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts", "build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs", "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", "domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs", "i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "npm run build && wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "npm run build && 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" "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
}, },
"keywords": [ "keywords": [
@@ -42,27 +42,34 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.5",
"@types/node": "^25.2.3", "@types/node": "^26.0.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.5.2",
"opencc-js": "^1.0.5", "opencc-js": "^1.3.2",
"postcss": "^8.5.6", "postcss": "^8.5.16",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"tsx": "^4.21.0", "tsx": "^4.22.4",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"wrangler": "^4.71.0" "wrangler": "^4.105.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.2.0",
"@tanstack/react-query": "^5.90.21", "@simplewebauthn/server": "^13.3.2",
"@zip.js/zip.js": "^2.8.22", "@tanstack/react-query": "^5.101.2",
"fflate": "^0.8.2", "@zip.js/zip.js": "^2.8.26",
"lucide-preact": "^0.575.0", "fflate": "^0.8.3",
"preact": "^10.28.4", "lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.9.0" "wouter": "^3.10.0"
} }
} }
+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();
+12 -1
View File
@@ -22,6 +22,17 @@ const intentionallyEnglishKeys = new Set([
'txt_dash', 'txt_dash',
'txt_text_3', '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)) { for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort(); const keys = Object.keys(table).sort();
@@ -40,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) {
} }
if (locale !== 'en') { 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) { if (sameAsEnglish.length > 40) {
errors.push({ errors.push({
locale, locale,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.5.2'; export const APP_VERSION = '1.7.1';
+7 -3
View File
@@ -9,15 +9,18 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_S3_REGION = 'auto'; export const BACKUP_DEFAULT_S3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav'; export type BackupDestinationType = 's3' | 'webdav';
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
export interface S3BackupDestination { export interface S3BackupDestination {
endpoint: string; endpoint: string;
bucket: string; bucket: string;
addressingStyle: S3BackupAddressingStyle;
region: string; region: string;
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
@@ -103,17 +106,18 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
return { return {
endpoint: '', endpoint: '',
bucket: '', bucket: '',
addressingStyle: 'path-style',
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
}; };
} }
return { return {
baseUrl: '', baseUrl: '',
username: '', username: '',
password: '', password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH, remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
}; };
} }
+11 -3
View File
@@ -5,10 +5,10 @@
accessTokenTtlSeconds: 7200, accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds. // 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). // Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。 // 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000, refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
@@ -44,6 +44,9 @@
// Public read-only request budget per IP per minute. // Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。 // 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120, publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute. // Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。 // 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30, sensitivePublicRequestsPerMinute: 30,
@@ -145,6 +148,11 @@
compatibility: { compatibility: {
// Single source of truth for /config.version and /api/version. // Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。 // /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0', 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; } as const;
+476
View File
@@ -0,0 +1,476 @@
import type { Env } from '../types';
import type { BackupDestinationRecord } from '../services/backup-config';
import {
BACKUP_SCHEDULER_WINDOW_MINUTES,
requireBackupDestination,
hasBackupSlotBetween,
isBackupDueNow,
loadBackupSettings,
} from '../services/backup-config';
import {
createRemoteBackupTransferSession,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
} from '../services/backup-uploader';
import { getBlobObject } from '../services/blob-store';
import { StorageService } from '../services/storage';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
interface BackupJobState {
token: string;
reason: string;
acquiredAt: string;
touchedAt: string;
expiresAtMs: number;
}
interface RemoteAttachmentChunkRequest {
destination: BackupDestinationRecord;
attachments: Array<{
blobName: string;
}>;
}
interface RemoteAttachmentDownloadRequest {
destination: BackupDestinationRecord;
blobName?: string | null;
}
interface RemoteAttachmentBatchDownloadRequest {
destination: BackupDestinationRecord;
blobNames?: string[] | null;
}
interface ConfiguredBackupRunRequest {
actorUserId?: string | null;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
targetDeviceIdentifier?: string | null;
trigger?: 'manual' | 'scheduled';
}
interface RemoteBackupRestoreRequest {
actorUserId?: string | null;
allowChecksumMismatch?: boolean;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
path?: string | null;
replaceExisting?: boolean;
targetDeviceIdentifier?: string | null;
}
function badRequest(message: string, status: number = 400): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
export class BackupTransferRunner {
private lastHeartbeatAt = 0;
constructor(
private readonly state: DurableObjectState,
private readonly env: Env
) {
}
private async acquireJob(reason: string): Promise<string | null> {
const nowMs = Date.now();
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
return null;
}
const token = crypto.randomUUID();
const nowIso = new Date(nowMs).toISOString();
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
token,
reason,
acquiredAt: nowIso,
touchedAt: nowIso,
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
this.lastHeartbeatAt = 0;
return token;
}
private async touchJob(token: string): Promise<void> {
const nowMs = Date.now();
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
this.lastHeartbeatAt = nowMs;
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token !== token) return;
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
...current,
touchedAt: new Date(nowMs).toISOString(),
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
}
private async releaseJob(token: string): Promise<void> {
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token === token) {
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
}
}
private async runConfiguredBackup(request: Request): Promise<Response> {
let body: ConfiguredBackupRunRequest;
try {
body = await request.json<ConfiguredBackupRunRequest>();
} catch {
return badRequest('Backup run payload is invalid');
}
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
const actorUserId = String(body.actorUserId || '').trim() || null;
if (trigger === 'manual' && !actorUserId) {
return badRequest('Manual backup run requires an actor');
}
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const progress = actorUserId
? async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(
this.env,
actorUserId,
event,
String(body.targetDeviceIdentifier || '').trim() || null
);
}
: null;
const result = await executeConfiguredBackup(
this.env,
storage,
actorUserId,
trigger,
body.destinationId || null,
() => this.touchJob(token),
progress,
body.auditMetadata || null
);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
return new Response(JSON.stringify({
object: 'backup-runner-result',
result,
settings,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async runScheduledBackups(): Promise<Response> {
const token = await this.acquireJob('scheduled');
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
let scanStartMs = Date.now();
while (true) {
await this.touchJob(token);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const now = new Date();
const dueDestinations = settings.destinations.filter((destination) =>
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
);
if (!dueDestinations.length) {
break;
}
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await this.touchJob(token);
try {
await executeConfiguredBackup(
this.env,
storage,
null,
'scheduled',
destination.id,
() => this.touchJob(token)
);
completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
}
}
return new Response(JSON.stringify({
ok: true,
completed,
failed: failures.length,
failures,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async restoreRemoteBackup(request: Request): Promise<Response> {
let body: RemoteBackupRestoreRequest;
try {
body = await request.json<RemoteBackupRestoreRequest>();
} catch {
return badRequest('Remote restore payload is invalid');
}
const actorUserId = String(body.actorUserId || '').trim() || null;
if (!actorUserId) {
return badRequest('Remote restore requires an actor');
}
const token = await this.acquireJob(`restore:${actorUserId}`);
if (!token) {
return badRequest('Another backup or restore run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const restoreFileNameFromPath = path.split('/').pop() || path;
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
const replaceExisting = !!body.replaceExisting;
await notifyUserBackupRestoreProgress(
this.env,
actorUserId,
{
operation: 'backup-restore',
source: 'remote',
step: 'remote_fetch_archive',
fileName: restoreFileNameFromPath,
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return badRequest('Remote backup file checksum does not match its filename');
}
const result = await importAndAuditRemoteBackupFile(
this.env,
storage,
actorUserId,
remoteFile,
destination,
path,
replaceExisting,
!checksumOk,
body.auditMetadata || null,
targetDeviceIdentifier,
() => this.touchJob(token)
);
return new Response(JSON.stringify(result.result), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
} finally {
await this.releaseJob(token);
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method !== 'POST') {
return badRequest('Not found', 404);
}
if (url.pathname === '/internal/run-configured-backup') {
return this.runConfiguredBackup(request);
}
if (url.pathname === '/internal/run-scheduled-backups') {
return this.runScheduledBackups();
}
if (url.pathname === '/internal/restore-remote-backup') {
return this.restoreRemoteBackup(request);
}
if (url.pathname === '/internal/download-remote-attachment') {
let body: RemoteAttachmentDownloadRequest;
try {
body = await request.json<RemoteAttachmentDownloadRequest>();
} catch {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !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',
},
});
}
}
+393 -35
View File
@@ -1,17 +1,32 @@
import { DurableObject, waitUntil } from 'cloudflare:workers'; import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types'; import type { Env } from '../types';
import { notifyMobilePush } from '../services/push-relay';
const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
type HubProtocol = 'json' | 'messagepack'; type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request';
interface WsAttachment { interface WsAttachment {
userId: string; kind: HubKind;
userId: string | null;
authRequestId: string | null;
handshakeComplete: boolean; handshakeComplete: boolean;
protocol: HubProtocol; protocol: HubProtocol;
deviceIdentifier: string | null; deviceIdentifier: string | null;
@@ -137,11 +152,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
function buildSignalRJsonInvocation( function buildSignalRJsonInvocation(
updateType: number, updateType: number,
payload: Record<string, unknown>, payload: Record<string, unknown>,
contextId: string | null contextId: string | null,
target: string = 'ReceiveMessage'
): string { ): string {
return JSON.stringify({ return JSON.stringify({
type: 1, type: 1,
target: 'ReceiveMessage', target,
arguments: [ arguments: [
{ {
ContextId: contextId, ContextId: contextId,
@@ -155,15 +171,16 @@ function buildSignalRJsonInvocation(
function buildSignalRMessagePackInvocation( function buildSignalRMessagePackInvocation(
updateType: number, updateType: number,
messagePayload: Record<string, unknown>, messagePayload: Record<string, unknown>,
contextId: string | null contextId: string | null,
target: string = 'ReceiveMessage'
): Uint8Array { ): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape: // SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments] // [type, headers, invocationId, target, arguments, streamIds]
const encodedPayload = encodeMsgPack([ const encodedPayload = encodeMsgPack([
1, 1,
{}, {},
null, null,
'ReceiveMessage', target,
[ [
{ {
ContextId: contextId, ContextId: contextId,
@@ -171,6 +188,7 @@ function buildSignalRMessagePackInvocation(
Payload: messagePayload, Payload: messagePayload,
}, },
], ],
[],
]); ]);
return frameSignalRBinary(encodedPayload); return frameSignalRBinary(encodedPayload);
} }
@@ -201,7 +219,9 @@ export class NotificationsHub extends DurableObject<Env> {
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim(); const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
const contextId = String(body?.contextId || '').trim() || null; const contextId = String(body?.contextId || '').trim() || null;
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; const rawUpdateType = body?.updateType;
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
const payload = body?.payload && typeof body.payload === 'object' const payload = body?.payload && typeof body.payload === 'object'
? body.payload ? body.payload
@@ -213,6 +233,20 @@ export class NotificationsHub extends DurableObject<Env> {
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
userId?: string;
authRequestId?: string;
contextId?: string | null;
} | null;
const userId = String(body?.userId || '').trim();
const authRequestId = String(body?.authRequestId || '').trim();
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') { if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), { return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200, status: 200,
@@ -222,7 +256,7 @@ export class NotificationsHub extends DurableObject<Env> {
}); });
} }
if (url.pathname !== '/notifications/hub') { if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
@@ -232,8 +266,13 @@ export class NotificationsHub extends DurableObject<Env> {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim(); const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null; const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
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 }); return new Response('Unauthorized', { status: 401 });
} }
@@ -248,7 +287,9 @@ export class NotificationsHub extends DurableObject<Env> {
this.ctx.acceptWebSocket(server, tags); this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({ server.serializeAttachment({
userId: requestUserId, kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
userId: isAnonymousAuthRequestHub ? null : requestUserId,
authRequestId: requestAuthRequestId,
handshakeComplete: false, handshakeComplete: false,
protocol: 'messagepack', protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier, deviceIdentifier: requestDeviceIdentifier,
@@ -274,7 +315,6 @@ export class NotificationsHub extends DurableObject<Env> {
attachment.handshakeComplete = true; attachment.handshakeComplete = true;
ws.serializeAttachment(attachment); ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK); ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return; return;
} catch { } catch {
// Ignore malformed pre-handshake payloads. // Ignore malformed pre-handshake payloads.
@@ -293,26 +333,22 @@ export class NotificationsHub extends DurableObject<Env> {
} }
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> { async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null; void ws;
const shouldBroadcast = !!attachment?.handshakeComplete; void code;
if (shouldBroadcast && attachment?.userId) { void reason;
this.broadcastDeviceStatus(attachment.userId); void wasClean;
}
} }
async webSocketError(ws: WebSocket, error: unknown): Promise<void> { async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null; void ws;
const shouldBroadcast = !!attachment?.handshakeComplete; void error;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
} }
private getOnlineDeviceIdentifiers(): string[] { private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>(); const out = new Set<string>();
for (const ws of this.ctx.getWebSockets()) { for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null; 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); out.add(attachment.deviceIdentifier);
} }
return Array.from(out); return Array.from(out);
@@ -349,16 +385,45 @@ export class NotificationsHub extends DurableObject<Env> {
} }
} }
private broadcastDeviceStatus(userId: string): void { private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
this.broadcastMessage( for (const ws of this.ctx.getWebSockets()) {
SIGNALR_UPDATE_TYPE_DEVICE_STATUS, const attachment = ws.deserializeAttachment() as WsAttachment | null;
{ if (
!attachment?.handshakeComplete ||
attachment.kind !== 'anonymous-auth-request' ||
attachment.authRequestId !== authRequestId
) {
continue;
}
const payload = {
UserId: userId, UserId: userId,
Date: new Date().toISOString(), Id: authRequestId,
}, };
null, try {
null if (attachment.protocol === 'json') {
); ws.send(buildSignalRJsonInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
} else {
ws.send(buildSignalRMessagePackInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
}
} catch {
try {
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
} }
} }
@@ -371,6 +436,243 @@ export function notifyUserVaultSync(
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null)); waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
} }
export function notifyUserCiphersSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
}
export function notifyUserCipherCreate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherUpdate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherDelete(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderCreate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderUpdate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderDelete(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendCreate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendUpdate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendDelete(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserLogout( export function notifyUserLogout(
env: Env, env: Env,
userId: string, userId: string,
@@ -392,13 +694,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
} }
} }
export async function notifyAuthRequestResponse(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/auth-request-response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId,
authRequestId,
contextId: contextId || null,
}),
});
} catch (error) {
console.error('Failed to broadcast auth request response notification:', error);
}
}
export function notifyUserAuthRequest(
env: Env,
userId: string,
authRequestId: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(
env,
userId,
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
new Date().toISOString(),
contextId ?? null,
null,
{
UserId: userId,
Id: authRequestId,
}
));
}
async function notifyUserUpdate( async function notifyUserUpdate(
env: Env, env: Env,
userId: string, userId: string,
updateType: number, updateType: number,
revisionDate: string, revisionDate: string,
contextId: string | null, contextId: string | null,
targetDeviceIdentifier: string | null targetDeviceIdentifier: string | null,
payloadOverride?: Record<string, unknown> | null
): Promise<void> { ): Promise<void> {
try { try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId); const id = env.NOTIFICATIONS_HUB.idFromName(userId);
@@ -414,12 +762,22 @@ async function notifyUserUpdate(
contextId: contextId || null, contextId: contextId || null,
updateType, updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null, targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: { payload: payloadOverride || {
UserId: userId, UserId: userId,
Date: revisionDate, Date: revisionDate,
}, },
}), }),
}); });
await notifyMobilePush(env, {
userId,
updateType,
revisionDate,
contextId,
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
});
} catch (error) { } catch (error) {
console.error('Failed to broadcast realtime notification:', error); console.error('Failed to broadcast realtime notification:', error);
} }
+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);
}
+473 -61
View File
@@ -1,13 +1,19 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { Env, User, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // users.master_password_hash is server-side login verification only. It does
@@ -63,6 +69,77 @@ function normalizeTotpSecret(input: string): string {
return out; 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 { function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
} }
@@ -90,34 +167,67 @@ async function verifyUserSecret(
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email); return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
} }
function toProfile(user: User, env: Env): ProfileResponse { function readBodyString(body: Record<string, unknown>, names: string[]): string {
void env; for (const name of names) {
const value = body[name];
if (typeof value === 'string') return value;
}
return '';
}
function readNestedString(source: unknown, path: string[]): string {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'string' ? current : '';
}
function readNestedNumber(source: unknown, path: string[]): number | undefined {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'number' ? current : undefined;
}
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 masterPasswordPolicyResponse(): Record<string, unknown> {
return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
object: 'masterPasswordPolicy',
};
}
function keysResponse(user: User): Record<string, unknown> {
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
return { return {
id: user.id, Key: user.key,
name: user.name, PublicKey: user.publicKey ?? '',
email: user.email, PrivateKey: user.privateKey ?? '',
emailVerified: true, AccountKeys: accountKeys,
premium: true, Object: 'keys',
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, publicKey: user.publicKey ?? '',
privateKey: user.privateKey ?? '',
accountKeys, accountKeys,
securityStamp: user.securityStamp || user.id, object: 'keys',
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
}; };
} }
@@ -227,14 +337,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Registration is temporarily unavailable, retry once', 409); return errorResponse('Registration is temporarily unavailable, retry once', 409);
} }
await storage.setRegistered(); await storage.setRegistered();
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId: user.id, actorUserId: user.id,
action: 'user.register.first_admin', action: 'user.register.first_admin',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email }), category: 'security',
createdAt: now, level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
}); });
return jsonResponse({ success: true, role: user.role }, 200); return jsonResponse({ success: true, role: user.role }, 200);
} }
@@ -243,30 +353,41 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId: user.id, actorUserId: user.id,
action: 'user.register.invite', action: 'user.register.invite',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }), category: 'security',
createdAt: now, level: 'info',
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
}); });
return jsonResponse({ success: true, role: user.role }, 200); return jsonResponse({ success: true, role: user.role }, 200);
@@ -352,7 +473,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT /api/accounts/profile // PUT /api/accounts/profile
@@ -378,8 +499,20 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
user.masterPasswordHint = masterPasswordHint; user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); 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)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
@@ -393,6 +526,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
secret?: string; secret?: string;
masterPasswordHash?: string; masterPasswordHash?: string;
verifyDevices?: boolean; verifyDevices?: boolean;
VerifyDevices?: boolean;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -400,7 +534,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (typeof body.verifyDevices !== 'boolean') { const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400); return errorResponse('verifyDevices must be true or false', 400);
} }
@@ -409,13 +544,38 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
user.verifyDevices = body.verifyDevices; user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); 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 }); return new Response(null, { status: 200 });
} }
// GET /api/accounts/keys
export async function handleGetKeys(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
return jsonResponse(keysResponse(user));
}
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -461,8 +621,22 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); 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); return jsonResponse(keysResponse(user));
} }
// POST/PUT /api/accounts/password // POST/PUT /api/accounts/password
@@ -476,6 +650,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
masterPasswordHash?: string; masterPasswordHash?: string;
currentPasswordHash?: string; currentPasswordHash?: string;
newMasterPasswordHash?: string; newMasterPasswordHash?: string;
masterPasswordHint?: string | null;
key?: string; key?: string;
newKey?: string; newKey?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
@@ -486,6 +661,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
kdfIterations?: number; kdfIterations?: number;
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
authenticationData?: Record<string, unknown>;
unlockData?: Record<string, unknown>;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -498,10 +675,16 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) { const newMasterPasswordHash =
body.newMasterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400); return errorResponse('newMasterPasswordHash is required', 400);
} }
const nextKey = body.newKey || body.key; const nextKey =
body.newKey ||
body.key ||
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']);
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey; const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) { if (nextKey && !looksLikeEncString(nextKey)) {
@@ -511,29 +694,37 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
} }
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism); const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType;
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey; if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf; if (typeof nextKdf === 'number') user.kdfType = nextKdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
user.masterPasswordHint = body.masterPasswordHint;
}
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({ AuthService.invalidateUserCache(user.id);
id: generateUUID(), await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'user.password.change', action: 'user.password.change',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: JSON.stringify({ email: user.email }), category: 'security',
createdAt: user.updatedAt, level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
}); });
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
@@ -552,8 +743,166 @@ 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 // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -561,7 +910,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -570,12 +925,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token); const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) { if (!verified) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
@@ -587,6 +954,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
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' }); return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
} }
@@ -601,6 +978,16 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
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' }); return jsonResponse({ enabled: false, object: 'twoFactor' });
} }
@@ -639,7 +1026,9 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
} }
return jsonResponse({ return jsonResponse({
Code: user.totpRecoveryCode,
code: user.totpRecoveryCode, code: user.totpRecoveryCode,
Object: 'twoFactorRecover',
object: 'twoFactorRecover', object: 'twoFactorRecover',
}); });
} }
@@ -671,7 +1060,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); 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); const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) { if (!recoverAttemptCheck.allowed) {
@@ -708,7 +1097,17 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey); 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({ return jsonResponse({
success: true, success: true,
@@ -739,23 +1138,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('User not found', 404); return errorResponse('User not found', 404);
} }
let body: { masterPasswordHash?: string }; let body: { masterPasswordHash?: string; authenticationData?: Record<string, unknown> };
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (!body.masterPasswordHash) { const masterPasswordHash =
body.masterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400); return errorResponse('masterPasswordHash is required', 400);
} }
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
return new Response(null, { status: 200 }); return jsonResponse(masterPasswordPolicyResponse());
} }
// POST /api/accounts/api-key // POST /api/accounts/api-key
@@ -801,6 +1203,16 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
} }
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); 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({ return jsonResponse({
+136 -18
View File
@@ -1,8 +1,9 @@
import { Env, User, Invite } from '../types'; import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store'; import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
@@ -24,16 +25,20 @@ async function writeAuditLog(
action: string, action: string,
targetType: string | null, targetType: string | null,
targetId: string | null, targetId: string | null,
metadata: Record<string, unknown> | null metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> { ): Promise<void> {
await storage.createAuditLog({ await writeAuditEvent(storage, {
id: generateUUID(),
actorUserId, actorUserId,
action, action,
targetType, targetType,
targetId, targetId,
metadata: metadata ? JSON.stringify(metadata) : null, category: action.startsWith('admin.user.') ? 'security' : 'system',
createdAt: new Date().toISOString(), level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
}); });
} }
@@ -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 // POST /api/admin/invites
export async function handleAdminCreateInvite( export async function handleAdminCreateInvite(
request: Request, request: Request,
@@ -115,9 +220,9 @@ export async function handleAdminCreateInvite(
}; };
await storage.createInvite(invite); await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, { await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours, expiresInHours,
}); }, request);
return jsonResponse(toInviteResponse(request, invite), 201); return jsonResponse(toInviteResponse(request, invite), 201);
} }
@@ -144,7 +249,7 @@ export async function handleAdminListInvites(
} }
// DELETE /api/admin/invites/:code // DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite( export async function handleAdminDeleteInvite(
request: Request, request: Request,
env: Env, env: Env,
actorUser: User, actorUser: User,
@@ -155,12 +260,14 @@ export async function handleAdminRevokeInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code); const deleted = await storage.deleteInvite(code);
if (!revoked) { if (!deleted) {
return errorResponse('Invite not found or already inactive', 404); return errorResponse('Invite not found', 404);
} }
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null); await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -170,16 +277,25 @@ export async function handleAdminDeleteAllInvites(
env: Env, env: Env,
actorUser: User actorUser: User
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') {
const deleted = await storage.deleteInvalidInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_invalid', 'invite', null, {
deleted,
}, request);
return jsonResponse({ deleted }, 200);
}
const deleted = await storage.deleteAllInvites(); const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, { await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted, deleted,
}); }, request);
return jsonResponse({ deleted }, 200); return jsonResponse({ deleted }, 200);
} }
@@ -222,9 +338,10 @@ export async function handleAdminSetUserStatus(
if (nextStatus === 'banned') { if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
} }
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus, status: nextStatus,
}); }, request);
return jsonResponse({ return jsonResponse({
id: target.id, id: target.id,
@@ -280,9 +397,10 @@ export async function handleAdminDeleteUser(
await storage.deleteRefreshTokensByUserId(target.id); await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id); await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, { await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email, targetEmail: target.email,
}); }, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
+84 -12
View File
@@ -1,16 +1,17 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
verifyAttachmentUploadToken, verifyAttachmentUploadToken,
verifyFileDownloadToken, verifyFileDownloadToken,
} from '../utils/jwt'; } from '../utils/jwt';
import { cipherToResponse } from './ciphers'; import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { import {
@@ -20,6 +21,7 @@ import {
getBlobStorageMaxBytes, getBlobStorageMaxBytes,
putBlobObject, putBlobObject,
} from '../services/blob-store'; } from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
@@ -30,6 +32,59 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'attachment';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable // Format file size to human readable
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`; if (bytes < 1024) return `${bytes} Bytes`;
@@ -53,6 +108,7 @@ async function runWithConcurrency<T>(
async function processAttachmentUpload( async function processAttachmentUpload(
request: Request, request: Request,
env: Env, env: Env,
cipher: Cipher,
attachment: Attachment, attachment: Attachment,
cipherId: string cipherId: string
): Promise<Response> { ): Promise<Response> {
@@ -94,6 +150,7 @@ async function processAttachmentUpload(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
@@ -154,6 +211,7 @@ export async function handleCreateAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
// Get updated cipher for response // Get updated cipher for response
@@ -197,7 +255,7 @@ export async function handleUploadAttachment(
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
export async function handlePublicUploadAttachment( export async function handlePublicUploadAttachment(
@@ -235,7 +293,7 @@ export async function handlePublicUploadAttachment(
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
// GET /api/ciphers/{cipherId}/attachment/{attachmentId} // GET /api/ciphers/{cipherId}/attachment/{attachmentId}
@@ -260,6 +318,7 @@ export async function handleGetAttachment(
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
// Generate short-lived download token // Generate short-lived download token
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET); const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
@@ -270,12 +329,12 @@ export async function handleGetAttachment(
return jsonResponse({ return jsonResponse({
object: 'attachment', object: 'attachment',
id: attachment.id, id: responseAttachment.id,
url: downloadUrl, url: downloadUrl,
fileName: attachment.fileName, fileName: responseAttachment.fileName,
key: attachment.key, key: responseAttachment.key,
size: String(Number(attachment.size) || 0), size: String(Number(responseAttachment.size) || 0),
sizeName: attachment.sizeName, sizeName: responseAttachment.sizeName,
}); });
} }
@@ -325,6 +384,7 @@ export async function handleUpdateAttachmentMetadata(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
return jsonResponse({ return jsonResponse({
@@ -390,9 +450,11 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -430,14 +492,24 @@ export async function handleDeleteAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
return jsonResponse({ return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments), Cipher: cipherResponse,
cipher: cipherResponse,
Object: 'deleteAttachment',
object: 'deleteAttachment',
}); });
} }
+285
View File
@@ -0,0 +1,285 @@
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 isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null {
return (
request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
null
);
}
function getCountryName(request: Request): string | null {
return request.headers.get('CF-IPCountry') || null;
}
function deviceTypeName(type: number): string {
const names: Record<number, string> = {
0: 'Android',
1: 'iOS',
2: 'Chrome Extension',
3: 'Firefox Extension',
4: 'Opera Extension',
5: 'Edge Extension',
6: 'Windows Desktop',
7: 'macOS Desktop',
8: 'Linux Desktop',
9: 'Chrome',
10: 'Firefox',
11: 'Opera',
12: 'Edge',
13: 'Internet Explorer',
14: 'Unknown Browser',
15: 'Android',
16: 'Windows UWP',
17: 'Safari',
18: 'Vivaldi',
19: 'Vivaldi Extension',
20: 'Safari Extension',
21: 'SDK',
22: 'Server',
23: 'Windows CLI',
24: 'macOS CLI',
25: 'Linux CLI',
26: 'DuckDuckGo',
};
return names[type] || `Device ${type}`;
}
function buildOrigin(request: Request): string {
return new URL(request.url).host;
}
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
return {
id: authRequest.id,
Id: authRequest.id,
publicKey: authRequest.publicKey,
PublicKey: authRequest.publicKey,
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
requestDeviceTypeValue: authRequest.requestDeviceType,
RequestDeviceTypeValue: authRequest.requestDeviceType,
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
requestIpAddress: authRequest.requestIpAddress,
RequestIpAddress: authRequest.requestIpAddress,
requestCountryName: authRequest.requestCountryName,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: 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);
}
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, {
approved,
responseDeviceIdentifier,
key,
masterPasswordHash,
});
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));
}
+538 -282
View File
File diff suppressed because it is too large Load Diff
+521 -38
View File
@@ -11,24 +11,66 @@ import {
PasswordHistory, PasswordHistory,
} from '../types'; } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserCipherCreate,
notifyUserCipherDelete,
notifyUserCipherUpdate,
notifyUserCiphersSync,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
// CONTRACT: // CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve // Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
// unknown/future client fields by default, then override only server-owned // unknown/future client fields by default, then override only server-owned
// fields. Any change to cipher response shape must be checked against /api/sync, // fields. Any change to cipher response shape must be checked against /api/sync,
// attachments, import/export, and current official clients. // attachments, import/export, and current official clients.
export interface CipherResponseOptions {
preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
}
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 { function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null; if (value == null) return null;
const normalized = String(value).trim(); const normalized = String(value).trim();
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function buildCipherPermissions(passthrough: Record<string, unknown>): { delete: boolean; restore: boolean } {
const raw = passthrough.permissions;
const source = raw && typeof raw === 'object' && !Array.isArray(raw)
? raw as Record<string, unknown>
: null;
return {
delete: readBooleanOrFallback(source?.delete, true),
restore: readBooleanOrFallback(source?.restore, true),
};
}
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
@@ -38,6 +80,60 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function notifyCipherCreateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherCreate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherDeleteForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherDelete(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined }; if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) { for (const key of aliases) {
@@ -83,6 +179,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return 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 { function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false; if (typeof value !== 'string') return false;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -107,15 +224,32 @@ function optionalEncString(value: unknown): string | null {
return isValidEncString(value) ? value.trim() : 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>>( function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined, source: T | null | undefined,
encryptedKeys: readonly string[] encryptedKeys: readonly string[] | Record<string, number>
): T | null { ): T | null {
if (!source || typeof source !== 'object') return source ?? null; if (!source || typeof source !== 'object') return source ?? null;
const next: Record<string, any> = { ...source }; 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; if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
next[key] = optionalEncString(next[key]); next[key] = optionalEncStringWithin(next[key], maxLength);
} }
return next as T; return next as T;
} }
@@ -139,20 +273,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); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; 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; if (!next) return null;
next.uris = Array.isArray(next.uris) next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
? next.uris requiresUriChecksum,
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum'])) preserveRepairableUris,
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null)) });
: null;
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials); next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next; 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 { function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null; if (!Array.isArray(credentials) || credentials.length === 0) return null;
const requiredEncryptedKeys = [ const requiredEncryptedKeys = [
@@ -223,6 +429,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 // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
@@ -241,6 +455,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
return formatted.length ? formatted : 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 { function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
if (!Array.isArray(fields) || fields.length === 0) return null; if (!Array.isArray(fields) || fields.length === 0) return null;
const out = fields const out = fields
@@ -280,12 +684,25 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean
// survive a round-trip without code changes. // survive a round-trip without code changes.
export function cipherToResponse( export function cipherToResponse(
cipher: Cipher, cipher: Cipher,
attachments: Attachment[] = [] attachments: Attachment[] = [],
options: CipherResponseOptions = {}
): CipherResponse { ): CipherResponse {
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); const responseCipherKey = optionalEncString(cipher.key);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); 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, [ const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title', 'title',
'firstName', 'firstName',
@@ -307,12 +724,17 @@ export function cipherToResponse(
'licenseNumber', 'licenseNumber',
]); ]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
const responsePermissions = buildCipherPermissions(passthrough);
return { return {
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
...passthrough, ...passthrough,
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
@@ -320,24 +742,23 @@ export function cipherToResponse(
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
archivedDate: archivedAt ?? null, archivedDate: archivedAt ?? null,
edit: true, edit: readBooleanOrFallback((passthrough as any).edit, true),
viewPassword: true, viewPassword: readBooleanOrFallback((passthrough as any).viewPassword, true),
permissions: { permissions: responsePermissions,
delete: true,
restore: true,
},
object: 'cipherDetails', object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], 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, name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes), notes: optionalEncString(cipher.notes),
login: normalizedLogin, login: normalizedLogin,
card: normalizedCard, card: normalizedCard,
identity: normalizedIdentity, identity: normalizedIdentity,
secureNote: normalizedSecureNote,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields), fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
key: optionalEncString(cipher.key), key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -371,12 +792,14 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds( const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id) filteredCiphers.map((cipher) => cipher.id)
); );
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap. // Build responses only for the current page to keep pagination cheap.
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments)); cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions));
} }
return jsonResponse({ return jsonResponse({
@@ -396,8 +819,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
} }
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments) cipherToResponse(cipher, attachments, responseOptions)
); );
} }
@@ -430,6 +854,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); 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(); const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
// then override only server-controlled fields. // then override only server-controlled fields.
@@ -447,7 +875,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
deletedAt: null, deletedAt: null,
}; };
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId); 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.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null); cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
@@ -457,6 +885,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -467,9 +897,11 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherCreateForRequest(request, env, cipher, revisionDate);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []), cipherToResponse(cipher, [], responseOptions),
200 200
); );
} }
@@ -502,8 +934,16 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData); const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
const 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); return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
} }
@@ -511,9 +951,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected. // Unknown/future fields from the client are preserved; server-controlled fields are protected.
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
const cipher: Cipher = { const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns) ...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) // Server-controlled fields (never from client)
id: existingCipher.id, id: existingCipher.id,
userId: existingCipher.userId, userId: existingCipher.userId,
@@ -521,7 +962,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
favorite: cipherData.favorite ?? existingCipher.favorite, favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(), updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
@@ -529,7 +970,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.folderId = normalizeOptionalId(incomingFolderId.value); cipher.folderId = normalizeOptionalId(incomingFolderId.value);
} }
if (incomingKey.present) { 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.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; cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
@@ -551,6 +995,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.fields = null; cipher.fields = null;
} }
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -558,13 +1004,16 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404); if (!folderOk) return errorResponse('Folder not found', 404);
} }
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments) cipherToResponse(cipher, attachments, responseOptions)
); );
} }
@@ -584,9 +1033,15 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
); );
} }
@@ -608,6 +1063,13 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, 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 }); return new Response(null, { status: 204 });
} }
@@ -629,6 +1091,12 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
folderId: cipher.folderId ?? null,
});
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -648,9 +1116,10 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
); );
} }
@@ -687,9 +1156,10 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
); );
} }
@@ -733,7 +1203,7 @@ async function buildCipherListResponse(
return jsonResponse({ return jsonResponse({
data: ciphers.map((cipher) => data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []) cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request))
), ),
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
@@ -763,10 +1233,11 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments) cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
); );
} }
@@ -788,7 +1259,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments) cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
); );
} }
@@ -811,6 +1282,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkArchiveCiphers(ids, userId); const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return buildCipherListResponse(request, storage, userId, ids); return buildCipherListResponse(request, storage, userId, ids);
@@ -835,6 +1307,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return buildCipherListResponse(request, storage, userId, ids); return buildCipherListResponse(request, storage, userId, ids);
@@ -858,6 +1331,10 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -881,6 +1358,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -917,6 +1395,11 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length,
requestedCount: ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+133 -9
View File
@@ -1,11 +1,16 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types'; import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types'; import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
function normalizeIdentifier(value: string | null | undefined): string { function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -219,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedUserKey: null, encryptedUserKey: null,
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
pushUuid: null,
pushToken: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null, deviceNote: null,
lastSeenAt: null, lastSeenAt: null,
@@ -265,9 +272,50 @@ export async function handleRevokeTrustedDevice(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed }); return jsonResponse({ success: true, removed });
} }
// POST /api/devices/authorized/:deviceIdentifier/permanent
// Upgrades an existing active 2FA remember-token record to permanent trust.
export async function handleTrustDevicePermanently(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
updated,
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
});
}
// DELETE /api/devices/:deviceIdentifier // DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice( export async function handleDeleteDevice(
request: Request, request: Request,
@@ -280,12 +328,24 @@ export async function handleDeleteDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
@@ -309,6 +369,15 @@ export async function handleUpdateDeviceName(
const device = await storage.getDevice(userId, normalized); const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404); 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)); return jsonResponse(buildDeviceResponse(device));
} }
@@ -327,7 +396,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null); notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
} }
@@ -419,6 +498,15 @@ export async function handleUntrustDevices(
if (!deviceIdentifier) continue; if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier); await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed }); return jsonResponse({ success: true, removed });
} }
@@ -454,28 +542,58 @@ export async function handleDeactivateDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.deactivate',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
// PUT /api/devices/identifier/{deviceIdentifier}/token // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken( export async function handleUpdateDeviceToken(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; const normalized = normalizeIdentifier(deviceIdentifier);
void env; if (!normalized) return errorResponse('Invalid device identifier', 400);
void userId;
void deviceIdentifier; const body = await readJsonBody(request);
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
if (!pushToken) return errorResponse('Invalid push token', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
const pushUuid = device.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: normalized,
type: device.type,
pushUuid,
pushToken,
});
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -501,9 +619,15 @@ export async function handleClearDeviceToken(
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; void request;
void env; const normalized = normalizeIdentifier(deviceIdentifier);
void userId; if (!normalized) return errorResponse('Invalid device identifier', 400);
void deviceIdentifier;
const storage = new StorageService(env.DB);
const cleared = await storage.clearDevicePushToken(userId, normalized);
if (cleared?.pushUuid) {
await unregisterMobilePushDevice(env, cleared.pushUuid);
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
+66 -1
View File
@@ -1,10 +1,16 @@
import { Env, Folder, FolderResponse } from '../types'; import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserFolderCreate,
notifyUserFolderDelete,
notifyUserFolderUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
@@ -15,6 +21,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); 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 // Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse { function folderToResponse(folder: Folder): FolderResponse {
return { return {
@@ -89,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderCreate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder), 200); return jsonResponse(folderToResponse(folder), 200);
} }
@@ -117,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderUpdate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder)); return jsonResponse(folderToResponse(folder));
} }
@@ -134,6 +173,15 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId); await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderDelete(env, {
userId,
folderId: id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -154,9 +202,26 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
return errorResponse('Folder ids are required', 400); return errorResponse('Folder ids are required', 400);
} }
const folders = (
await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id);
return folder && folder.userId === userId ? folder : null;
}))
).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const folder of folders) {
notifyUserFolderDelete(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+361 -44
View File
@@ -4,25 +4,34 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends'; import { issueSendAccessToken } from './sends';
import { registerMobilePushDevice } from '../services/push-relay';
import { import {
buildAccountKeys, buildAccountKeys,
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } 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';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code. // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// Keep request parsing backward-compatible with historical provider values (8 / 100). // 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_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null): string | null { function resolveTotpSecret(userSecret: string | null): string | null {
@@ -32,6 +41,55 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
}
async function persistIdentityDevicePushToken(
env: Env,
storage: StorageService,
userId: string,
deviceSession: { identifier: string; sessionStamp: string } | null,
deviceType: number,
body: Record<string, string>
): Promise<void> {
if (!deviceSession) return;
const pushToken = readDevicePushToken(body);
if (!pushToken) return;
const device = await storage.getDevice(userId, deviceSession.identifier);
if (!device) return;
const pushUuid = device.pushUuid || generateUUID();
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
const registered = await registerMobilePushDevice(env, {
userId,
deviceIdentifier: deviceSession.identifier,
type: device.type || deviceType,
pushUuid,
pushToken,
});
console.info('Mobile push token updated from identity token request', {
userId,
deviceIdentifier: deviceSession.identifier,
deviceType: device.type || deviceType,
pushUuid,
pushTokenLength: pushToken.length,
relayRegistered: registered,
});
}
function shouldUseWebSession(request: Request): boolean { function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
} }
@@ -60,6 +118,14 @@ function constantTimeEquals(a: string, b: string): boolean {
return diff === 0; 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 { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ const parts = [
@@ -114,19 +180,32 @@ function buildPreloginResponse(
}; };
} }
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response { function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
const providers = includeRecoveryCode return {
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE] minComplexity: 0,
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; minLength: 0,
const providers2: Record<string, null> = {}; requireUpper: false,
for (const provider of providers) providers2[provider] = null; requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
Object: 'masterPasswordPolicy',
object: 'masterPasswordPolicy',
};
}
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 = { const customResponse = {
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null, SsoEmail2faSessionToken: null,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
@@ -212,10 +291,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Login with password // Login with password
const email = body.username?.toLowerCase(); const email = body.username?.toLowerCase();
const passwordHash = body.password; const passwordHash = body.password;
const twoFactorToken = body.twoFactorToken; const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
const twoFactorProvider = body.twoFactorProvider; const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorRemember = body.twoFactorRemember; const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const loginIdentifier = `${clientIdentifier}:${email}`; const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
@@ -240,11 +320,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
if (user.status !== 'active') { if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier); 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); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
authRequest.type === 0 &&
authRequest.approved === true &&
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
}
} else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
}
if (!valid) { 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( return recordFailedLoginAndBuildResponse(
rateLimit, rateLimit,
loginIdentifier, loginIdentifier,
@@ -256,7 +385,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -266,7 +394,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload. // respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) { if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode); return twoFactorRequiredResponse('Two factor required.');
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -281,16 +409,20 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || 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) normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) { ) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) { if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
@@ -320,10 +452,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Persist device only after successful password + (optional) 2FA verification. // Persist device only after successful password + (optional) 2FA verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
@@ -332,15 +461,34 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
if (validatedAuthRequestId) {
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
}
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(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 = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
@@ -348,6 +496,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: masterPasswordPolicyResponse(),
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'webauthn') {
const 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 persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.passkey.login.success',
category: 'auth',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
@@ -358,12 +626,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
@@ -380,7 +648,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope; const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`; const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) { if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
@@ -404,19 +672,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
if (user.status !== 'active') { if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier); 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); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier); 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); return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
} }
// Persist device only after successful client credential verification. // Persist device only after successful client credential verification.
const deviceSession = const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) { if (deviceSession) {
await storage.upsertDevice( await storage.upsertDevice(
user.id, user.id,
@@ -425,6 +716,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
@@ -434,6 +726,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(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 = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
@@ -450,9 +757,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -538,8 +843,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessToken(refreshToken); const result = await auth.refreshAccessTokenDetailed(refreshToken);
if (!result) { 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); const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request) return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null) ? withWebRefreshCookie(request, invalidResponse, null)
@@ -576,9 +895,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
+7 -3
View File
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers'; import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
// Bitwarden client import request format // Bitwarden client import request format
interface CiphersImportRequest { interface CiphersImportRequest {
@@ -19,7 +19,7 @@ interface CiphersImportRequest {
sshKey?: any | null; sshKey?: any | null;
key?: string | null; key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
totp?: string | null; totp?: string | null;
@@ -195,7 +195,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
uris: login.uris?.map((u: any) => ({ uris: login.uris?.map((u: any) => ({
...u, ...u,
uri: u.uri ?? null, uri: u.uri ?? null,
uriChecksum: null, uriChecksum: u.uriChecksum ?? null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: login.totp ?? null, totp: login.totp ?? null,
@@ -252,6 +252,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) {
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
}
cipherRows.push(cipher); cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id }); cipherMapRows.push({ index: i, sourceId, id: cipher.id });
+15
View File
@@ -56,3 +56,18 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
} }
return stub.fetch(new Request(forwardedUrl.toString(), request)); return stub.fetch(new Request(forwardedUrl.toString(), request));
} }
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
if (!authRequestId) return errorResponse('Token is required', 400);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const 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));
}
+51 -3
View File
@@ -16,6 +16,9 @@ import {
formatSize, formatSize,
getAliasedProp, getAliasedProp,
normalizeEmails, normalizeEmails,
notifySendCreateForRequest,
notifySendDeleteForRequest,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseDate, parseDate,
parseFileLength, parseFileLength,
@@ -29,6 +32,28 @@ import {
setSendPassword, setSendPassword,
validateDeletionDate, validateDeletionDate,
} from './sends-shared'; } 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( async function processSendFileUpload(
request: Request, request: Request,
@@ -77,6 +102,7 @@ async function processSendFileUpload(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
} }
@@ -227,6 +253,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -350,6 +377,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
return errorResponse('Server configuration error', 500); return errorResponse('Server configuration error', 500);
@@ -597,12 +625,12 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -620,6 +648,11 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId); await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -651,13 +684,19 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId); const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const send of sends) {
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
}
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
} }
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -669,12 +708,16 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
@@ -687,6 +730,11 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
+20 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
@@ -21,6 +22,7 @@ import {
getSafeJwtSecret, getSafeJwtSecret,
hasEmailAuth, hasEmailAuth,
isSendAvailable, isSendAvailable,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseStoredSendData, parseStoredSendData,
resolveSendFromIdOrAccessId, resolveSendFromIdOrAccessId,
@@ -33,6 +35,14 @@ import {
verifySendPasswordHashB64, verifySendPasswordHashB64,
} from './sends-shared'; } from './sends-shared';
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'send-file';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> { export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId); const sendId = fromAccessId(accessId);
@@ -90,6 +100,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
} }
const creatorIdentifier = await getCreatorIdentifier(storage, send); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -163,6 +174,7 @@ export async function handleAccessSendFile(
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret); const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url); const url = new URL(request.url);
@@ -203,6 +215,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
} }
const creatorIdentifier = await getCreatorIdentifier(storage, send); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -242,6 +255,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret); const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url); const url = new URL(request.url);
@@ -282,6 +296,9 @@ export async function handleDownloadSendFile(
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const send = await storage.getSend(sendId);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) { if (!firstUse) {
@@ -290,9 +307,11 @@ export async function handleDownloadSendFile(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
+51 -1
View File
@@ -1,5 +1,10 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserSendCreate,
notifyUserSendDelete,
notifyUserSendUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
export function notifySendCreateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendCreate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendUpdateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendUpdate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendDeleteForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendDelete(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined }; if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) { for (const key of aliases) {
+38 -33
View File
@@ -1,25 +1,34 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types'; import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers'; import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
buildAccountKeys,
buildUserDecryptionCompat, buildUserDecryptionCompat,
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules'; import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
import { buildProfileResponse } from '../utils/profile-response';
// CONTRACT: // CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper. // /api/sync reuses cipherToResponse() as the single cipher response shaper.
// Filtering invalid cipher responses here protects clients from stored rows that // Filtering invalid cipher responses here protects clients from stored rows that
// would otherwise make official apps fail after an HTTP 200 sync. // would otherwise make official apps fail after an HTTP 200 sync.
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields. // Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request { function buildSyncCacheRequest(
request: Request,
userId: string,
revisionDate: string,
accountPasskeyCacheTag: string,
excludeDomains: boolean,
excludeSends: boolean,
preserveRepairableUris: boolean
): Request {
const url = new URL(request.url); const url = new URL(request.url);
const cacheUrl = new 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 url.origin
); );
return new Request(cacheUrl.toString(), { method: 'GET' }); return new Request(cacheUrl.toString(), { method: 'GET' });
@@ -43,14 +52,26 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends'); const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam); const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
return errorResponse('User not found', 404); return errorResponse('User not found', 404);
} }
const revisionDate = await storage.getRevisionDate(userId); const [revisionDate, accountPasskeys] = await Promise.all([
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends); 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); const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) { if (cachedResponse) {
return cachedResponse; return cachedResponse;
@@ -63,37 +84,17 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
storage.getAttachmentsByUserId(userId), storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]); ]);
const accountKeys = buildAccountKeys(user); const webAuthnPrfOptions = accountPasskeys
const userDecryptionOptions = buildUserDecryptionOptions(user); .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = { const profile: ProfileResponse = buildProfileResponse(user, env);
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []); const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
@@ -125,11 +126,15 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
{ omitExcludedGlobals: true } { omitExcludedGlobals: true }
), ),
policies: [], policies: [],
policiesNew: [],
sends: sendResponses, sends: sendResponses,
UserDecryption: { UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null, TrustedDeviceOption: null,
KeyConnectorOption: null, KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions,
V2UpgradeToken: null,
Object: 'userDecryption', Object: 'userDecryption',
}, },
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
+2
View File
@@ -1,5 +1,6 @@
import { Env } from './types'; import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub'; import { NotificationsHub } from './durable/notifications-hub';
import { BackupTransferRunner } from './durable/backup-transfer-runner';
import { handleRequest } from './router'; import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response'; import { applyCors, jsonResponse } from './utils/response';
@@ -127,3 +128,4 @@ export default {
}; };
export { NotificationsHub }; export { NotificationsHub };
export { BackupTransferRunner };
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
+20 -2
View File
@@ -4,9 +4,13 @@ import {
handleAdminCreateInvite, handleAdminCreateInvite,
handleAdminListInvites, handleAdminListInvites,
handleAdminDeleteAllInvites, handleAdminDeleteAllInvites,
handleAdminRevokeInvite, handleAdminDeleteInvite,
handleAdminSetUserStatus, handleAdminSetUserStatus,
handleAdminDeleteUser, handleAdminDeleteUser,
handleAdminListAuditLogs,
handleAdminGetAuditLogSettings,
handleAdminUpdateAuditLogSettings,
handleAdminClearAuditLogs,
} from './handlers/admin'; } from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup'; import { handleAdminBackupRoute } from './router-admin-backup';
@@ -21,6 +25,20 @@ export async function handleAdminRoute(
return handleAdminListUsers(request, env, actorUser); return handleAdminListUsers(request, env, actorUser);
} }
if (path === '/api/admin/logs' && method === 'GET') {
return handleAdminListAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'DELETE') {
return handleAdminClearAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs/settings') {
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
return null;
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method); const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse; if (adminBackupResponse) return adminBackupResponse;
@@ -34,7 +52,7 @@ export async function handleAdminRoute(
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i); const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') { if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]); const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode); return handleAdminDeleteInvite(request, env, actorUser, inviteCode);
} }
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i); const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
+79 -4
View File
@@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile, handleUpdateProfile,
handleGetKeys,
handleSetKeys, handleSetKeys,
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
@@ -11,6 +12,10 @@ import {
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
} from './handlers/accounts'; } from './handlers/accounts';
@@ -66,6 +71,20 @@ import {
import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAuthenticatedDeviceRoute } from './router-devices';
import { handleAdminRoute } from './router-admin'; import { handleAdminRoute } from './router-admin';
import { handleGetDomains, handleUpdateDomains } from './handlers/domains'; import { 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( export async function handleAuthenticatedRoute(
request: Request, request: Request,
@@ -97,8 +116,10 @@ export async function handleAuthenticatedRoute(
return handleChangePassword(request, env, userId); return handleChangePassword(request, env, userId);
} }
if (path === '/api/accounts/keys' && method === 'POST') { if (path === '/api/accounts/keys') {
return handleSetKeys(request, env, userId); if (method === 'GET') return handleGetKeys(request, env, userId);
if (method === 'POST') return handleSetKeys(request, env, userId);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/accounts/totp') { if (path === '/api/accounts/totp') {
@@ -111,6 +132,25 @@ export async function handleAuthenticatedRoute(
return handleGetTotpRecoveryCode(request, env, userId); return handleGetTotpRecoveryCode(request, env, userId);
} }
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if (path === '/api/two-factor/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') { if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId); return handleGetRevisionDate(request, env, userId);
} }
@@ -131,6 +171,28 @@ export async function handleAuthenticatedRoute(
return handleRotateApiKey(request, env, userId); return handleRotateApiKey(request, env, userId);
} }
if (path === '/api/webauthn' || path === '/webauthn') {
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
}
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
}
const accountPasskeyDeleteMatch =
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
if (accountPasskeyDeleteMatch && method === 'POST') {
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
}
if (path === '/api/sync' && method === 'GET') { if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId); return handleSync(request, env, userId);
} }
@@ -232,8 +294,21 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
} }
if (path.startsWith('/api/auth-requests')) { if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
return jsonResponse({ data: [], object: 'list', continuationToken: null }); if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/pending') {
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/')) { if (path === '/api/collections' || path.startsWith('/api/collections/')) {
+25 -14
View File
@@ -11,6 +11,7 @@ import {
handleDeactivateDevice, handleDeactivateDevice,
handleRevokeAllTrustedDevices, handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleTrustDevicePermanently,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceName, handleUpdateDeviceName,
@@ -19,6 +20,10 @@ import {
handleClearDeviceToken, handleClearDeviceToken,
} from './handlers/devices'; } from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
request: Request, request: Request,
env: Env, env: Env,
@@ -26,25 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/api/devices') { if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null; return null;
} }
if (path === '/api/devices/authorized') { if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null; return null;
} }
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') { if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
if (deleteDeviceMatch && method === 'GET') { if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier); return handleGetDevice(request, env, userId, deviceIdentifier);
@@ -54,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
if (updateDeviceNameMatch && method === 'PUT') { if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier); return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
} }
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
} }
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i); const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) { if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]); const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier); return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i); const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) { if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]); const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier); return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
} }
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i); const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier); return handleClearDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i); const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
if (identifierRetrieveKeysMatch && method === 'POST') { if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]); const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier); return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i); const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) { if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]); const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier); return handleDeactivateDevice(request, env, userId, deviceIdentifier);
} }
if (path === '/api/devices/update-trust' && method === 'POST') { if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId); return handleUpdateDeviceTrust(request, env, userId);
} }
if (path === '/api/devices/untrust' && method === 'POST') { if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
return handleUntrustDevices(request, env, userId); return handleUntrustDevices(request, env, userId);
} }
+98 -14
View File
@@ -9,18 +9,25 @@ import {
} from './handlers/sends'; } from './handlers/sends';
import { handleKnownDevice } from './handlers/devices'; import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import { import {
handleRegister, handleRegister,
handleGetPasswordHint, handleGetPasswordHint,
handleRecoverTwoFactor, handleRecoverTwoFactor,
} from './handlers/accounts'; } from './handlers/accounts';
import {
handleCreateAuthRequest,
handleGetAuthRequestResponse,
} from './handlers/auth-requests';
import { handlePublicDownloadAttachment } from './handlers/attachments'; import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments'; import { handlePublicUploadAttachment } from './handlers/attachments';
import { import {
handleAnonymousNotificationsHub,
handleNotificationsHub, handleNotificationsHub,
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
@@ -115,11 +122,12 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
featureStates: { featureStates: {
'cipher-key-encryption': true, 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true, 'pm-19148-innovation-archive': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
'web-push': false, 'web-push': false,
}, },
@@ -144,6 +152,7 @@ function normalizeIconHost(rawHost: string): string | null {
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500; 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_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
@@ -179,12 +188,62 @@ async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); 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 { function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, { return new Response(body, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
@@ -216,21 +275,21 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!isSafeWebsiteIconContentType(contentType)) continue;
if (!source.rejectImage) { const contentLength = getPositiveContentLength(resp.headers);
return iconResponse(resp.body, resp.headers.get('Content-Type')); 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;
} }
const contentLength = Number(resp.headers.get('Content-Length') || '');
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
return iconResponse(resp.body, resp.headers.get('Content-Type'));
}
const bytes = await resp.arrayBuffer();
if (bytes.byteLength === 0) continue;
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
return iconResponse(bytes, resp.headers.get('Content-Type')); return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch { } catch {
continue; continue;
@@ -286,6 +345,8 @@ export async function handlePublicRoute(
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(iconMatch[1], fallbackMode);
} }
@@ -337,6 +398,19 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]); return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
} }
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleCreateAuthRequest(request, env);
}
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
}
if (path === '/identity/connect/token' && method === 'POST') { if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env); return handleToken(request, env);
} }
@@ -370,6 +444,12 @@ export async function handlePublicRoute(
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAccountPasskeyAssertionOptions(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
@@ -390,7 +470,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin)); return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
@@ -418,5 +498,9 @@ export async function handlePublicRoute(
if (path === '/notifications/hub' && method === 'GET') { if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env); return handleNotificationsHub(request, env);
} }
if (path === '/notifications/anonymous-hub' && method === 'GET') {
return handleAnonymousNotificationsHub(request, env);
}
return null; 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);
}
+73 -29
View File
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations). // The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive. // This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000; const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000; const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry { interface CachedUserEntry {
@@ -23,6 +24,22 @@ export interface VerifiedAccessContext {
user: User; 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 { export class AuthService {
private storage: StorageService; private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>(); private static userCache = new Map<string, CachedUserEntry>();
@@ -32,6 +49,25 @@ export class AuthService {
this.storage = new StorageService(env.DB); this.storage = new StorageService(env.DB);
} }
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined { private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId); const cached = AuthService.userCache.get(userId);
if (!cached) return undefined; if (!cached) return undefined;
@@ -98,7 +134,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes. // Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> { async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', 'raw',
@@ -116,19 +152,16 @@ export class AuthService {
const bytes = new Uint8Array(bits); const bytes = new Uint8Array(bits);
let binary = ''; let binary = '';
for (const b of bytes) binary += String.fromCharCode(b); for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary); return SERVER_HASH_PREFIX + btoa(binary);
} }
// Verify password: hash the input the same way, then constant-time compare. // Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$". if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
// Legacy accounts (created before the upgrade) store raw client hashes without prefix. return this.constantTimeEquals(inputHash, storedHash);
if (email && storedHash.startsWith('$s$')) {
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
} }
// Legacy path: direct constant-time comparison of raw client hashes. const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(inputHash, storedHash); return this.constantTimeEquals(serverHash, storedHash);
} }
private constantTimeEquals(a: string, b: string): boolean { private constantTimeEquals(a: string, b: string): boolean {
@@ -204,34 +237,45 @@ export class AuthService {
} }
// Refresh access token // Refresh access token
async refreshAccessToken( async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const record = await this.storage.getRefreshTokenRecord(refreshToken); const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null; if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(record.userId); const user = await this.storage.getUserById(record.userId);
if (!user) return null; if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') { if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken); await this.storage.deleteRefreshToken(refreshToken);
return null; return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
} }
let device: { identifier: string; sessionStamp: string } | null = null; let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) { if (!record.deviceIdentifier || !record.deviceSessionStamp) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); await this.storage.deleteRefreshToken(refreshToken);
if (!boundDevice) { return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
} }
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
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); 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;
} }
} }
+42 -1
View File
@@ -67,6 +67,8 @@ export interface BackupPayload {
folders: SqlRow[]; folders: SqlRow[];
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -300,6 +302,8 @@ export function validateBackupPayloadContents(
const folderRows = ensureRowArray(payload.db.folders, 'folders'); const folderRows = ensureRowArray(payload.db.folders, 'folders');
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -372,6 +376,37 @@ export function validateBackupPayloadContents(
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`); throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
} }
} }
const accountPasskeyIds = new Set<string>();
const accountPasskeyCredentialIds = new Set<string>();
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const 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( export async function buildBackupArchive(
@@ -390,7 +425,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
@@ -398,6 +433,8 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -425,6 +462,8 @@ export async function buildBackupArchive(
folders: folderRows.length, folders: folderRows.length,
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -447,6 +486,8 @@ export async function buildBackupArchive(
folders: folderRows, folders: folderRows,
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+84 -31
View File
@@ -16,6 +16,7 @@ import {
type BackupRuntimeState, type BackupRuntimeState,
type BackupScheduleConfig, type BackupScheduleConfig,
type BackupSettings, type BackupSettings,
type S3BackupAddressingStyle,
type S3BackupDestination, type S3BackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
createBackupRandomId, createBackupRandomId,
@@ -25,6 +26,7 @@ import {
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
@@ -35,6 +37,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
@@ -109,6 +112,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint); const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket); const bucket = asTrimmedString(source.bucket);
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
const addressingStyle: S3BackupAddressingStyle =
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
const accessKeyId = asTrimmedString(source.accessKeyId); const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey); const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto'; const region = asTrimmedString(source.region) || 'auto';
@@ -131,6 +137,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
return { return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket, bucket,
addressingStyle,
region, region,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
@@ -318,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
return new Map(destinations.map((destination) => [destination.id, destination])); return new Map(destinations.map((destination) => [destination.id, destination]));
} }
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: normalizeRuntime(null),
})),
};
}
function serializeRuntimeState(settings: BackupSettings): string {
return JSON.stringify({
version: 1,
destinations: Object.fromEntries(
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
),
});
}
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
const entries = Object.entries(parsed.destinations || {})
.filter(([id]) => !!asTrimmedString(id))
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
return new Map(entries);
} catch {
return new Map();
}
}
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
})),
};
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings { export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone)); return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
} }
@@ -381,27 +429,30 @@ export function normalizeBackupSettingsInput(
} }
export function serializeBackupSettings(settings: BackupSettings): string { export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings); return JSON.stringify(stripRuntimeFromSettings(settings));
} }
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) { if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone); const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
const envelope = parseBackupSettingsEnvelope(raw); const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) { if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone); return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch { } catch {
throw new Error('Backup settings need administrator reactivation after restore'); throw new Error('Backup settings need administrator reactivation after restore');
} }
@@ -409,15 +460,29 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> { export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
await saveBackupRuntimeStates(storage, settings);
}
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
}
export async function updateBackupDestinationRuntime(
storage: StorageService,
destinationId: string,
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
): Promise<BackupRuntimeState> {
const runtimes = await loadBackupRuntimeStates(storage);
const current = runtimes.get(destinationId) || normalizeRuntime(null);
const next = normalizeRuntime(mutator(current));
runtimes.set(destinationId, next);
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
version: 1,
destinations: Object.fromEntries(runtimes.entries()),
}));
return next;
} }
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> { export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
@@ -442,12 +507,6 @@ export async function normalizeImportedBackupSettingsValue(
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone); const settings = parseBackupSettings(decrypted, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch { } catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it. // Keep imported portable recovery data intact until an admin signs in and repairs it.
@@ -455,12 +514,6 @@ export async function normalizeImportedBackupSettingsValue(
} }
} }
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} }
@@ -609,9 +662,9 @@ export function hasBackupSlotBetween(
const endMs = endExclusive.getTime(); const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false; if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs); const dayCursor = new Date(startMs);
@@ -633,7 +686,7 @@ export function hasBackupSlotBetween(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue; if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue; if (lastSuccessMs >= slotStartMs) continue;
return true; return true;
} }
} }
@@ -650,9 +703,9 @@ export function isBackupDueNow(
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone); const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay( const slotStarts = getBackupSlotStartsForLocalDay(
@@ -665,7 +718,7 @@ export function isBackupDueNow(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue; if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false; if (lastSuccessMs >= slotStartMs) return false;
return true; return true;
} }
return false; return false;
+42
View File
@@ -24,6 +24,8 @@ type BackupTableName =
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
| 'attachments'; | 'attachments';
@@ -33,6 +35,8 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
'attachments', 'attachments',
@@ -49,6 +53,8 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
attachments: number; attachments: number;
@@ -168,6 +174,8 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM attachments', 'DELETE FROM attachments',
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -292,6 +300,8 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row, ...row,
@@ -629,6 +639,26 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true 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( await runInsertBatch(
db, db,
tableName('folders'), tableName('folders'),
@@ -697,6 +727,8 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length, attachments: (db.attachments || []).length,
@@ -719,6 +751,8 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length, attachments: restored.restoredAttachments.length,
@@ -759,6 +793,8 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length, attachments: restored.restoredAttachments.length,
@@ -835,6 +871,8 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length, attachments: (db.attachments || []).length,
@@ -857,6 +895,8 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).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, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length, attachments: restored.restoredAttachments.length,
@@ -903,6 +943,8 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length, attachments: restored.restoredAttachments.length,
+18 -15
View File
@@ -6,6 +6,8 @@ import type { Env, User } from '../types';
// server's scheduled backup runner. // server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for // - 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. // 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 // New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider // pattern or a deliberately documented replacement. Do not store provider
@@ -186,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
): Promise<string> { ): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users); const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET); const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey); const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
@@ -205,18 +204,22 @@ export async function encryptBackupSettingsEnvelope(
const wraps: BackupSettingsPortableWrap[] = []; const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) { for (const user of eligibleUsers) {
const publicKey = await importPortablePublicKey(user.publicKey!); try {
const wrappedKey = new Uint8Array( const publicKey = await importPortablePublicKey(user.publicKey!);
await crypto.subtle.encrypt( const wrappedKey = new Uint8Array(
{ name: PORTABLE_ALGORITHM }, await crypto.subtle.encrypt(
publicKey, { name: PORTABLE_ALGORITHM },
portableDek publicKey,
) portableDek
); )
wraps.push({ );
userId: user.id, wraps.push({
wrappedKey: bytesToBase64(wrappedKey), userId: user.id,
}); wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
} }
const envelope: BackupSettingsEnvelopeV2 = { const envelope: BackupSettingsEnvelopeV2 = {
+117 -59
View File
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array; bytes: Uint8Array;
} }
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions { export interface RemoteBackupFilePutOptions {
contentType?: string; contentType?: string;
} }
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
} }
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> { async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
return (await statWebDavFile(config, relativePath)) !== null;
}
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password); const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath); const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,15 +452,40 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`); throw new Error(`WebDAV existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 'webdav',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
}
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
const hostname = endpoint.hostname.toLowerCase();
const bucketName = bucket.trim().toLowerCase();
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
} }
function s3BucketBaseUrl(config: S3BackupDestination): URL { 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 { function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
@@ -501,7 +537,7 @@ async function putToS3(
options: RemoteBackupFilePutOptions = {} options: RemoteBackupFilePutOptions = {}
): Promise<void> { ): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); 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); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) { if (!response.ok) {
@@ -521,61 +557,68 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath); const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 listing failed: ${response.status}`);
}
const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath); const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = []; const items: RemoteBackupItem[] = [];
let continuationToken = '';
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { do {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); const url = s3BucketBaseUrl(config);
if (!fullPrefix) continue; url.searchParams.set('list-type', '2');
const relative = rootPrefix url.searchParams.set('delimiter', '/');
? fullPrefix === rootPrefix if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
? '' if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
: fullPrefix.startsWith(`${rootPrefix}/`)
? fullPrefix.slice(rootPrefix.length + 1) const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 listing failed: ${response.status}`);
}
const xml = await response.text();
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
if (!fullPrefix) continue;
const relative = rootPrefix
? fullPrefix === rootPrefix
? ''
: fullPrefix.startsWith(`${rootPrefix}/`)
? fullPrefix.slice(rootPrefix.length + 1)
: ''
: fullPrefix;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative) continue;
const itemPath = normalizedRelative.replace(/\/+$/, '');
if ((parentPath(itemPath) || '') !== currentPath) continue;
items.push({
path: itemPath,
name: basename(itemPath) || itemPath,
isDirectory: true,
size: null,
modifiedAt: null,
});
}
for (const content of extractXmlBlocks(xml, 'Contents')) {
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
const relative = rootPrefix
? fullKey.startsWith(`${rootPrefix}/`)
? fullKey.slice(rootPrefix.length + 1)
: '' : ''
: fullPrefix; : fullKey;
const normalizedRelative = trimSlashes(relative); const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative) continue; if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
const itemPath = normalizedRelative.replace(/\/+$/, ''); items.push({
if ((parentPath(itemPath) || '') !== currentPath) continue; path: normalizedRelative,
items.push({ name: basename(normalizedRelative) || normalizedRelative,
path: itemPath, isDirectory: false,
name: basename(itemPath) || itemPath, size: Number(extractXmlFirst(content, 'Size') || 0) || null,
isDirectory: true, modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
size: null, });
modifiedAt: null, }
});
}
for (const content of extractXmlBlocks(xml, 'Contents')) { continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || ''); } while (continuationToken);
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
const relative = rootPrefix
? fullKey.startsWith(`${rootPrefix}/`)
? fullKey.slice(rootPrefix.length + 1)
: ''
: fullKey;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
items.push({
path: normalizedRelative,
name: basename(normalizedRelative) || normalizedRelative,
isDirectory: false,
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
});
}
const deduped = new Map<string, RemoteBackupItem>(); const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
@@ -594,7 +637,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
throw new Error('Please select a backup file'); throw new Error('Please select a backup file');
} }
const objectKey = normalizeS3ObjectKey(config, normalized); 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); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`); throw new Error(`S3 download failed: ${response.status}`);
@@ -610,7 +653,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> { async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); 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); const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`); throw new Error(`S3 delete failed: ${response.status}`);
@@ -618,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
} }
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
return (await statS3File(config, relativePath)) !== null;
}
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 existence check failed: ${response.status}`); throw new Error(`S3 existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 's3',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
interface ConfiguredDestinationAdapter { interface ConfiguredDestinationAdapter {
@@ -637,6 +690,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -647,6 +701,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>; download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>; deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>; exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
} }
function resolveConfiguredDestinationAdapter( function resolveConfiguredDestinationAdapter(
@@ -664,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 's3') { if (destination.type === 's3') {
@@ -676,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
}; };
} }
@@ -711,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
download: async (relativePath: string) => adapter.download(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
}; };
} }
+275
View File
@@ -0,0 +1,275 @@
import type { Env } from '../types';
import {
setConfigValue as saveConfigValue,
} from './storage-config-repo';
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
const PUSH_REQUEST_TIMEOUT_MS = 5000;
interface CachedPushAccessToken {
token: string;
expiresAt: number;
}
let cachedPushAccessToken: CachedPushAccessToken | null = null;
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
console.error(errorMessage, error);
return null;
} finally {
clearTimeout(timeout);
}
}
function randomInstallationEmail(): string {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
return `${localPart}@nodewarden.app`;
}
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const [id, key] = await Promise.all([
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
]);
const normalizedId = String(id || '').trim();
const normalizedKey = String(key || '').trim();
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
}
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const existing = await getPushInstallationCredentials(db);
if (existing) return existing;
const response = await fetchPushEndpoint(
INSTALLATIONS_URI,
{
method: 'POST',
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
},
body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(),
region: 'us',
}),
},
'Failed to request Bitwarden push installation:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
const id = String(body?.id || '').trim();
const key = String(body?.key || '').trim();
if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key');
return null;
}
await Promise.all([
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
]);
return { id, key };
}
async function getPushAccessToken(env: Env): Promise<string | null> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return null;
const now = Date.now();
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
return cachedPushAccessToken.token;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api.push',
client_id: `installation.${credentials.id}`,
client_secret: credentials.key,
});
const response = await fetchPushEndpoint(
`${PUSH_IDENTITY_URI}/connect/token`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
},
'Failed to get Bitwarden push relay token:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
const token = String(body?.access_token || '').trim();
if (!token) {
console.error('Bitwarden push relay token response did not include an access_token');
return null;
}
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
cachedPushAccessToken = {
token,
expiresAt: now + Math.floor(expiresInSeconds * 500),
};
return token;
}
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
const token = await getPushAccessToken(env);
if (!token) return false;
const response = await fetchPushEndpoint(
`${PUSH_RELAY_URI}${path}`,
{
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
},
`Bitwarden push relay request failed: ${path}`
);
if (!response) return false;
if (!response.ok) {
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
return false;
}
return true;
}
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
const source = payload || {};
const id = source.Id ?? source.id;
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
if (id != null) {
return {
id,
userId: source.UserId ?? source.userId ?? userId,
organizationId,
collectionIds,
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
};
}
return {
userId: source.UserId ?? source.userId ?? userId,
date: source.Date ?? source.date ?? revisionDate,
};
}
export async function registerMobilePushDevice(
env: Env,
input: {
userId: string;
deviceIdentifier: string;
type: number;
pushUuid: string;
pushToken: string;
}
): Promise<boolean> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return false;
return postToPushRelay(env, '/push/register', {
deviceId: input.pushUuid,
pushToken: input.pushToken,
userId: input.userId,
type: input.type,
identifier: input.deviceIdentifier,
installationId: credentials.id,
});
}
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
}
export async function notifyMobilePush(
env: Env,
input: {
userId: string;
updateType: number;
revisionDate: string;
contextId: string | null;
payload: Record<string, unknown> | null | undefined;
}
): Promise<void> {
const hasPushDevice = await env.DB
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(input.userId, '')
.first<{ '1': number }>();
if (!hasPushDevice) return;
let actingPushUuid: string | null = null;
if (input.contextId) {
const row = await env.DB
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(input.userId, input.contextId)
.first<{ push_uuid: string | null }>();
actingPushUuid = row?.push_uuid ?? null;
}
await postToPushRelay(env, '/push/send', {
userId: input.userId,
organizationId: null,
deviceId: actingPushUuid,
identifier: input.contextId,
type: input.updateType,
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
clientType: null,
installationId: null,
});
}
@@ -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 };
}
+157 -6
View File
@@ -1,5 +1,72 @@
import type { AuditLog, Invite } from '../types'; import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> { export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db await db
.prepare( .prepare(
@@ -50,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> { export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") .prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code) .bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
const result = await db
.prepare('DELETE FROM invites WHERE code = ?')
.bind(code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
const now = new Date().toISOString();
const result = await db
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
.bind(now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteAllInvites(db: D1Database): Promise<number> { export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run(); const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
@@ -77,8 +176,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> { export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
) )
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) .bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run(); .run();
} }
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+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);
}
+12 -3
View File
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
'favorite', 'favorite',
'reprompt', 'reprompt',
'key', 'key',
'attachments',
'Attachments',
'attachments2',
'Attachments2',
'createdAt', 'createdAt',
'created_at', 'created_at',
'creationDate', 'creationDate',
@@ -83,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null, archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null, deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
}; };
} catch { } catch {
console.error('Corrupted cipher data, id:', row.id); console.error('Corrupted cipher data, id:', row.id);
@@ -240,7 +244,9 @@ export async function getCiphersPage(
limit: number, limit: number,
offset: number offset: number
): Promise<Cipher[]> { ): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; const whereDeleted = includeDeleted
? ''
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
const res = await db const res = await db
.prepare( .prepare(
`SELECT ${selectCipherColumns()} FROM ciphers `SELECT ${selectCipherColumns()} FROM ciphers
@@ -337,7 +343,10 @@ export async function bulkArchiveCiphers(
`UPDATE ciphers `UPDATE ciphers
SET archived_at = ?, updated_at = ?, SET archived_at = ?, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL` WHERE user_id = ? AND id IN (${placeholders})
AND deleted_at IS NULL
AND json_extract(data, '$.deletedAt') IS NULL
AND json_extract(data, '$.deletedDate') IS NULL`
) )
.bind(now, now, userId, ...chunk) .bind(now, now, userId, ...chunk)
.run(); .run();
+81 -3
View File
@@ -1,4 +1,5 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types'; import type { Device, TrustedDeviceTokenSummary, User } from '../types';
import { generateUUID } from '../utils/uuid';
type GetUserByEmail = (email: string) => Promise<User | null>; type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>; type TrustedTokenKeyFn = (token: string) => Promise<string>;
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
pushUuid: row.push_uuid ?? null,
pushToken: row.push_token ?? null,
lastSeenAt: row.last_seen_at ?? null, lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -38,13 +41,15 @@ export async function upsertDevice(
const existingDevice = await getDeviceById(userId, deviceIdentifier); const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
'last_seen_at=excluded.last_seen_at, ' + 'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
@@ -57,6 +62,7 @@ export async function upsertDevice(
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
effectivePushUuid,
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
@@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
return row ? mapDeviceRow(row) : null; return row ? mapDeviceRow(row) : null;
} }
export async function updateDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<{ pushUuid: string | null } | null> {
const existing = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
if (!existing) return null;
await db
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(new Date().toISOString(), userId, deviceIdentifier)
.run();
return { pushUuid: existing.push_uuid ?? null };
}
export async function getDevicePushUuid(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<string | null> {
const row = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
return row?.push_uuid ?? null;
}
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(userId, '')
.first<{ '1': number }>();
return !!row;
}
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> { export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await db const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
@@ -233,6 +296,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
} }
export async function updateTrustedTwoFactorTokensExpiryByDevice(
db: D1Database,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<number> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const result = await db
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
.bind(expiresAtMs, userId, deviceIdentifier, now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken( export async function saveTrustedTwoFactorDeviceToken(
db: D1Database, db: D1Database,
trustedTokenKey: TrustedTokenKeyFn, trustedTokenKey: TrustedTokenKeyFn,
+14 -4
View File
@@ -44,9 +44,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?` WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
) )
.bind(now, userId, folderId) .bind(now, userId, folderId, folderId, folderId)
.run(); .run();
} }
@@ -71,9 +76,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})` WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
) )
.bind(now, userId, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); .run();
await db await db
+1 -36
View File
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
db: D1Database, db: D1Database,
refreshTokenKey: RefreshTokenKeyFn, refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn, maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>, deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string token: string
): Promise<RefreshTokenRecord | null> { ): Promise<RefreshTokenRecord | null> {
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
await maybeCleanupExpiredRefreshTokens(now); await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
let row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
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) return null;
if (row.expires_at && row.expires_at < now) { if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
+41 -2
View File
@@ -78,17 +78,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' + 'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE invites ADD COLUMN used_by TEXT',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' + 'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -97,17 +104,49 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
'ALTER TABLE devices ADD COLUMN push_token TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT', 'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, 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 (' + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
+35
View File
@@ -0,0 +1,35 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function consumeTotpLoginCounter(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
userId: string,
timeCounter: number,
consumedAtMs: number,
markerTtlMs: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
.bind(consumedAtMs - markerTtlMs)
.run();
cleanedUpAt = consumedAtMs;
}
const result = await db
.prepare(
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(user_id, time_counter) DO NOTHING'
)
.bind(userId, timeCounter, consumedAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+239 -7
View File
@@ -1,5 +1,6 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { ensurePushInstallationCredentials } from './push-relay';
import { ensureStorageSchema } from './storage-schema'; import { ensureStorageSchema } from './storage-schema';
import { import {
getConfigValue as getStoredConfigValue, getConfigValue as getStoredConfigValue,
@@ -18,13 +19,21 @@ import {
saveUser as saveStoredUser, saveUser as saveStoredUser,
} from './storage-user-repo'; } from './storage-user-repo';
import { import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteInvite as deleteStoredInvite,
deleteInvalidInvites as deleteStoredInvalidInvites,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites, listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
revokeInvite as revokeStoredInvite, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
bulkDeleteFolders as deleteStoredFolders, bulkDeleteFolders as deleteStoredFolders,
@@ -82,10 +91,12 @@ import {
import { import {
deleteDevice as deleteStoredDevice, deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId, deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDevicePushToken as clearStoredDevicePushToken,
clearDeviceKeys as clearStoredDeviceKeys, clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice, getDevice as findStoredDevice,
getDevicePushUuid as findStoredDevicePushUuid,
getDevicesByUserId as listStoredDevicesByUserId, getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
@@ -96,11 +107,26 @@ import {
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import {
createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
updateAuthRequestResponse as updateStoredAuthRequestResponse,
} from './storage-auth-request-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -109,6 +135,18 @@ import {
getUserDomainSettings as getStoredUserDomainSettings, getUserDomainSettings as getStoredUserDomainSettings,
saveUserDomainSettings as saveStoredUserDomainSettings, saveUserDomainSettings as saveStoredUserDomainSettings,
} from './storage-domain-rules-repo'; } from './storage-domain-rules-repo';
import {
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
} from './storage-account-passkey-repo';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
@@ -116,7 +154,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2'; const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -129,10 +168,13 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -147,6 +189,16 @@ export class StorageService {
return stmt.bind(...values.map(v => v === undefined ? null : v)); 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 { private sqlChunkSize(fixedBindCount: number): number {
return Math.max( return Math.max(
1, 1,
@@ -190,10 +242,14 @@ export class StorageService {
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY); const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
if (schemaVersion !== STORAGE_SCHEMA_VERSION) { const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
? !(await this.hasRequiredSchemaTables())
: true;
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
await ensureStorageSchema(this.db); await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
} }
await ensurePushInstallationCredentials(this.db);
StorageService.schemaVerified = true; StorageService.schemaVerified = true;
} }
@@ -266,8 +322,20 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId); return markStoredInviteUsed(this.db, code, userId);
} }
async revokeInvite(code: string): Promise<boolean> { async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
return revokeStoredInvite(this.db, code); return assignStoredInviteUsedBy(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async deleteInvite(code: string): Promise<boolean> {
return deleteStoredInvite(this.db, code);
}
async deleteInvalidInvites(): Promise<number> {
return deleteStoredInvalidInvites(this.db);
} }
async deleteAllInvites(): Promise<number> { async deleteAllInvites(): Promise<number> {
@@ -278,6 +346,22 @@ export class StorageService {
await createStoredAuditLog(this.db, log); await createStoredAuditLog(this.db, log);
} }
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
// --- Domain rules --- // --- Domain rules ---
async getUserDomainSettings(userId: string) { async getUserDomainSettings(userId: string) {
@@ -301,6 +385,73 @@ export class StorageService {
await this.updateRevisionDate(userId); 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 --- // --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> { async getCipher(id: string): Promise<Cipher | null> {
@@ -463,7 +614,6 @@ export class StorageService {
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this), this.maybeCleanupExpiredRefreshTokens.bind(this),
this.saveRefreshToken.bind(this),
this.deleteRefreshToken.bind(this), this.deleteRefreshToken.bind(this),
token token
); );
@@ -590,6 +740,27 @@ export class StorageService {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
} }
async updateDevicePushToken(
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
}
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
}
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
}
async userHasPushDevice(userId: string): Promise<boolean> {
return getUserHasPushDevice(this.db, userId);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
@@ -602,6 +773,45 @@ export class StorageService {
return deleteStoredDevicesByUserId(this.db, userId); return deleteStoredDevicesByUserId(this.db, userId);
} }
// --- Auth requests / Login with device ---
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
await createStoredAuthRequest(this.db, request);
}
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestById(this.db, id);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId);
}
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredPendingAuthRequestsByUserId(this.db, userId);
}
async updateAuthRequestResponse(
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
}
): Promise<boolean> {
return updateStoredAuthRequestResponse(this.db, id, userId, update);
}
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
return markStoredAuthRequestAuthenticated(this.db, id);
}
async pruneExpiredAuthRequests(): Promise<number> {
return pruneStoredExpiredAuthRequests(this.db);
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> { async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
return listStoredTrustedTokenSummaries(this.db, userId); return listStoredTrustedTokenSummaries(this.db, userId);
} }
@@ -614,6 +824,10 @@ export class StorageService {
return deleteStoredTrustedTokensByUserId(this.db, userId); return deleteStoredTrustedTokensByUserId(this.db, userId);
} }
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) --- // --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken( async saveTrustedTwoFactorDeviceToken(
@@ -630,6 +844,24 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
} }
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
const result = await consumeStoredTotpLoginCounter(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastTotpReplayCleanupAt,
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
userId,
timeCounter,
consumedAtMs,
StorageService.TOTP_REPLAY_MARKER_TTL_MS
);
if (result.cleanedUpAt !== null) {
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
// --- Revision dates --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+91 -3
View File
@@ -2,6 +2,7 @@
export interface Env { export interface Env {
DB: D1Database; DB: D1Database;
NOTIFICATIONS_HUB: DurableObjectNamespace; NOTIFICATIONS_HUB: DurableObjectNamespace;
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
ASSETS?: { ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; 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). // Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace; ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string; JWT_SECRET: string;
TOTP_SECRET?: string; WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string;
} }
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
@@ -96,9 +99,13 @@ export interface Invite {
export interface AuditLog { export interface AuditLog {
id: string; id: string;
actorUserId: string | null; actorUserId: string | null;
actorEmail?: string | null;
action: string; action: string;
category: 'auth' | 'security' | 'device' | 'data' | 'system';
level: 'info' | 'warn' | 'error' | 'security';
targetType: string | null; targetType: string | null;
targetId: string | null; targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null; metadata: string | null;
createdAt: string; createdAt: string;
} }
@@ -224,17 +231,72 @@ export interface Device {
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
pushUuid: string | null;
pushToken: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null; lastSeenAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; 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 { export interface DevicePendingAuthRequest {
id: string; id: string;
creationDate: 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 { export interface DeviceResponse {
id: string; id: string;
userId?: string | null; userId?: string | null;
@@ -368,6 +430,14 @@ export interface MasterPasswordUnlock {
Object: string; Object: string;
} }
export interface WebAuthnPrfDecryptionOption {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
Object?: string;
}
export interface UserDecryptionOptions { export interface UserDecryptionOptions {
HasMasterPassword: boolean; HasMasterPassword: boolean;
Object: string; Object: string;
@@ -375,6 +445,7 @@ export interface UserDecryptionOptions {
MasterPasswordUnlock: MasterPasswordUnlock; MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null; TrustedDeviceOption: null;
KeyConnectorOption: null; KeyConnectorOption: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
} }
// API Response types // API Response types
@@ -395,8 +466,18 @@ export interface TokenResponse {
ResetMasterPassword: boolean; ResetMasterPassword: boolean;
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireNumbers: boolean;
requireSpecial: boolean;
enforceOnLogin: boolean;
Object: string; Object: string;
object?: string;
} | null; } | null;
ApiUseKeyConnector?: boolean; ApiUseKeyConnector?: boolean;
AccountKeys?: any | null; AccountKeys?: any | null;
@@ -425,12 +506,13 @@ export interface ProfileResponse {
accountKeys: any | null; accountKeys: any | null;
securityStamp: string; securityStamp: string;
organizations: any[]; organizations: any[];
organizationsNew?: any[];
providers: any[]; providers: any[];
providerOrganizations: any[]; providerOrganizations: any[];
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
verifyDevices?: boolean; verifyDevices: boolean;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
object: string; object: string;
@@ -489,12 +571,18 @@ export interface SyncResponse {
ciphers: CipherResponse[]; ciphers: CipherResponse[];
domains: any; domains: any;
policies: any[]; policies: any[];
policiesNew?: any[];
sends: SendResponse[]; sends: SendResponse[];
UserDecryption?: { UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null; MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null; TrustedDeviceOption?: null;
KeyConnectorOption?: null; KeyConnectorOption?: null;
WebAuthnPrfOption?: null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
V2UpgradeToken?: {
WrappedUserKey1: string;
WrappedUserKey2: string;
} | null;
Object?: string; Object?: string;
} | null; } | null;
// PascalCase for desktop/browser clients // 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;
}
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env;
const organizations: any[] = [];
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations,
organizationsNew: organizations,
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
role: user.role,
status: user.status,
object: 'profile',
};
}
+9 -5
View File
@@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean {
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } { function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url); const url = new URL(request.url);
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
if (!origin) { if (!origin) {
return { allowOrigin: null, allowCredentials: false }; return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
} }
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
@@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
if (isExtensionOrigin(origin)) { if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
return { allowOrigin: null, allowCredentials: false }; return { allowOrigin: null, allowCredentials: false };
} }
@@ -98,7 +100,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY'); headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
return token.replace(/\s+/g, ''); return token.replace(/\s+/g, '');
} }
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> { export async function findMatchingTotpCounter(
secretRaw: string,
tokenRaw: string,
nowMs: number = Date.now()
): Promise<number | null> {
const token = normalizeToken(tokenRaw); const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false; if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw); const secret = base32Decode(secretRaw);
if (!secret) return false; if (!secret) return null;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false; let matchedCounter: number | null = null;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const candidateCounter = currentCounter + delta;
const expected = await hotp(secret, candidateCounter);
// Constant-time comparison: always check all windows, never short-circuit. // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected); const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token); const b = new TextEncoder().encode(token);
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
for (let i = 0; i < a.length && i < b.length; i++) { for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i]; diff |= a[i] ^ b[i];
} }
if (diff === 0) matched = true; if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
} }
return matched; return matchedCounter;
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+5 -2
View File
@@ -1,4 +1,4 @@
import { User, UserDecryptionOptions } from '../types'; import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
function normalizeOptionalPublicKey(value: unknown): string { function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return ''; if (value == null) return '';
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
publicKeyEncryptionKeyPair: { publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey, wrappedPrivateKey: user.privateKey,
publicKey, publicKey,
signedPublicKey: null,
Object: 'publicKeyEncryptionKeyPair', Object: 'publicKeyEncryptionKeyPair',
}, },
Object: 'privateKeys', Object: 'privateKeys',
@@ -40,7 +41,8 @@ export function buildMasterPasswordUnlock(
} }
export function buildUserDecryptionOptions( 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 { ): UserDecryptionOptions {
return { return {
HasMasterPassword: true, HasMasterPassword: true,
@@ -48,6 +50,7 @@ export function buildUserDecryptionOptions(
MasterPasswordUnlock: buildMasterPasswordUnlock(user), MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null, TrustedDeviceOption: null,
KeyConnectorOption: null, KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOption,
}; };
} }
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+6
View File
@@ -18,6 +18,12 @@
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" /> <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="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <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> <title>NodeWarden</title>
<style> <style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Full-bleed background for any/maskable -->
<rect width="512" height="512" fill="#116FF9"/>
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+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" }]
}
]
}
+624 -46
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -13,10 +13,11 @@ interface AdminPageProps {
error: string; error: string;
onRefresh: () => void; onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>; onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onDeleteInvite: (code: string) => Promise<void>;
} }
export default function AdminPage(props: AdminPageProps) { export default function AdminPage(props: AdminPageProps) {
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
<h3>{t('txt_invites')}</h3> <h3>{t('txt_invites')}</h3>
<div className="actions admin-invites-head-actions"> <div className="actions admin-invites-head-actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}> <button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')} <RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteInvalidInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_invalid')}
</button> </button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}> <button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')} <Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
@@ -184,11 +188,9 @@ export default function AdminPage(props: AdminPageProps) {
> >
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button> </button>
{invite.status === 'active' && ( <button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}> <Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')} </button>
</button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -1,8 +1,9 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import ThemeSwitch from '@/components/ThemeSwitch'; import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -46,13 +47,19 @@ function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin'; 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) { export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; 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 isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules'; const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute; const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/security/devices'; 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 [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false); const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null); const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
@@ -173,7 +180,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))} {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'))} {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('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))} {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'))}
</> </>
); );
@@ -217,7 +225,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
managementActive, managementActive,
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))} {isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</> </>
)} )}
</> </>
@@ -233,6 +242,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<span className="mobile-page-title">{props.currentPageTitle}</span> <span className="mobile-page-title">{props.currentPageTitle}</span>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<NetworkStatusBadge />
<div className="user-chip"> <div className="user-chip">
<ShieldUser size={16} /> <ShieldUser size={16} />
<span>{props.profile?.email}</span> <span>{props.profile?.email}</span>
@@ -302,7 +312,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
</div> </div>
</aside> </aside>
<main className="content"> <main className="content">
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}> <div key={routeAnimationKey} className={`route-stage ${isDomainRulesRoute ? 'route-stage-fixed' : ''} ${isLogRoute ? 'route-stage-log-fixed' : ''}`}>
<AppMainRoutes {...props.mainRoutesProps} /> <AppMainRoutes {...props.mainRoutesProps} />
</div> </div>
</main> </main>
+2 -1
View File
@@ -12,6 +12,7 @@ export interface AppConfirmState {
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel?: () => void;
} }
interface AppGlobalOverlaysProps { interface AppGlobalOverlaysProps {
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm} onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/> />
<ConfirmDialog <ConfirmDialog
+101 -39
View File
@@ -1,13 +1,14 @@
import { lazy, Suspense } from 'preact/compat'; import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, Globe2, 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 type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -17,6 +18,7 @@ const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage')); const AdminPage = lazy(() => import('@/components/AdminPage'));
const LogCenterPage = lazy(() => import('@/components/LogCenterPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage')); const ImportPage = lazy(() => import('@/components/ImportPage'));
@@ -55,6 +57,7 @@ export interface AppMainRoutesProps {
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
currentDeviceIdentifier: string;
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
authorizedDevicesError: string; authorizedDevicesError: string;
domainRules: DomainRules | null; domainRules: DomainRules | null;
@@ -79,6 +82,7 @@ export interface AppMainRoutesProps {
onDeleteVaultItem: (cipher: Cipher) => Promise<void>; onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>; onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>; onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>; onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
@@ -104,11 +108,21 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
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;
pendingAuthRequestsRefreshing: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
@@ -116,31 +130,39 @@ export interface AppMainRoutesProps {
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>; onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void; onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onRefreshAdmin: () => void; onRefreshAdmin: () => void;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>; onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onDeleteInvite: (code: string) => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>;
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; 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 isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
const importPageContent = ( const importPageContent = (
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
@@ -207,6 +229,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onDelete={props.onDeleteVaultItem} onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem} onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem} onUnarchive={props.onUnarchiveVaultItem}
onRestore={props.onRestoreVaultItems}
onBulkDelete={props.onBulkDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
@@ -252,6 +275,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
onListAccountPasskeys={props.onListAccountPasskeys}
onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
@@ -270,7 +297,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span> <span>{t('nav_account_settings')}</span>
</Link> </Link>
<Link href="/security/devices" className="mobile-settings-link"> <Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} /> <Shield size={18} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
</Link> </Link>
@@ -288,6 +315,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
</Link> </Link>
)} )}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
@@ -304,31 +337,42 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LoadingState card lines={4} /> <LoadingState card lines={4} />
) : null} ) : null}
</Route> </Route>
<Route path="/security/devices"> {deviceManagementRoutePaths.map((path) => (
<div className="stack"> <Route key={path} path={path}>
{props.mobileLayout && ( <div className="stack">
<div className="mobile-settings-subhead"> {props.mobileLayout && (
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}> <div className="mobile-settings-subhead">
<span className="btn-icon" aria-hidden="true">{"<"}</span> <button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
{t('txt_back')} <span className="btn-icon" aria-hidden="true">{"<"}</span>
</button> {t('txt_back')}
</div> </button>
)} </div>
<Suspense fallback={<RouteContentFallback />}> )}
<SecurityDevicesPage <Suspense fallback={<RouteContentFallback />}>
devices={props.authorizedDevices} <SecurityDevicesPage
loading={props.authorizedDevicesLoading} devices={props.authorizedDevices}
error={props.authorizedDevicesError} currentDeviceIdentifier={props.currentDeviceIdentifier}
onRefresh={() => void props.onRefreshAuthorizedDevices()} loading={props.authorizedDevicesLoading}
onRenameDevice={props.onRenameAuthorizedDevice} error={props.authorizedDevicesError}
onRevokeTrust={props.onRevokeDeviceTrust} pendingAuthRequests={props.pendingAuthRequests}
onRemoveDevice={props.onRemoveDevice} pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRevokeAll={props.onRevokeAllDeviceTrust} pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRemoveAll={props.onRemoveAllDevices} onRefresh={() => void props.onRefreshAuthorizedDevices()}
/> onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
</Suspense> onApproveAuthRequest={props.onApproveAuthRequest}
</div> onDenyAuthRequest={props.onDenyAuthRequest}
</Route> onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice}
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
</Suspense>
</div>
</Route>
))}
<Route path="/settings/domain-rules"> <Route path="/settings/domain-rules">
<div className="stack domain-rules-route"> <div className="stack domain-rules-route">
{props.mobileLayout && ( {props.mobileLayout && (
@@ -370,14 +414,32 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
error={props.adminError} error={props.adminError}
onRefresh={props.onRefreshAdmin} onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite} onCreateInvite={props.onCreateInvite}
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
onDeleteAllInvites={props.onDeleteAllInvites} onDeleteAllInvites={props.onDeleteAllInvites}
onToggleUserStatus={props.onToggleUserStatus} onToggleUserStatus={props.onToggleUserStatus}
onDeleteUser={props.onDeleteUser} onDeleteUser={props.onDeleteUser}
onRevokeInvite={props.onRevokeInvite} onDeleteInvite={props.onDeleteInvite}
/> />
</Suspense> </Suspense>
</div> </div>
</Route> </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) => ( {importRoutePaths.map((path) => (
<Route key={path} path={path}> <Route key={path} path={path}>
{renderImportPageRoute()} {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>
);
}
+61 -9
View File
@@ -1,5 +1,6 @@
import { useState } from 'preact/hooks'; 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 StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -22,19 +23,25 @@ interface AuthViewsProps {
relaxedLoginInput?: boolean; relaxedLoginInput?: boolean;
authPlaceholder?: string; authPlaceholder?: string;
unlockPlaceholder?: string; unlockPlaceholder?: string;
pendingAction: 'login' | 'register' | 'unlock' | null; pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
loginValues: LoginValues; loginValues: LoginValues;
pendingPasskeyPasswordEmail?: string | null;
passkeyPassword: string;
registerValues: RegisterValues; registerValues: RegisterValues;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
unlockPassword: string; unlockPassword: string;
emailForLock: string; emailForLock: string;
loginHintLoading: boolean; loginHintLoading: boolean;
onChangeLogin: (next: LoginValues) => void; onChangeLogin: (next: LoginValues) => void;
onChangePasskeyPassword: (password: string) => void;
onChangeRegister: (next: RegisterValues) => void; onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void; onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void; onSubmitLogin: () => void;
onSubmitPasskey: () => void;
onSubmitPasskeyUnlock: () => void;
onSubmitPasskeyPassword: () => void;
onSubmitRegister: () => void; onSubmitRegister: () => void;
onSubmitUnlock: () => void; onSubmitUnlock: () => void;
onGotoLogin: () => void; onGotoLogin: () => void;
@@ -76,14 +83,16 @@ function PasswordField(props: {
export default function AuthViews(props: AuthViewsProps) { export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login'; const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey';
const registerBusy = props.pendingAction === 'register'; const registerBusy = props.pendingAction === 'register';
const unlockBusy = props.pendingAction === 'unlock'; const unlockBusy = props.pendingAction === 'unlock';
const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail;
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim(); const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
if (props.mode === 'locked') { if (props.mode === 'locked') {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}> <StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -114,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
<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" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button> </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> <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" /> <LogOut size={16} className="btn-icon" />
{t('txt_log_out')} {t('txt_log_out')}
</button> </button>
@@ -132,7 +150,7 @@ export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'register') { if (props.mode === 'register') {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}> <StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@@ -216,13 +234,41 @@ export default function AuthViews(props: AuthViewsProps) {
return ( return (
<div className="auth-page"> <div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}> <StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (passkeyPasswordPending) {
props.onSubmitPasskeyPassword();
return;
}
props.onSubmitLogin(); 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"> <label className="field">
<span>{t('txt_email')}</span> <span>{t('txt_email')}</span>
<input <input
@@ -255,15 +301,21 @@ export default function AuthViews(props: AuthViewsProps) {
: t('txt_show_password_hint')} : t('txt_show_password_hint')}
</button> </button>
</div> </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" /> <LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')} {loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button> </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> <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" /> <UserPlus size={16} className="btn-icon" />
{t('txt_create_account')} {t('txt_create_account')}
</button> </button>
</>
)}
</form> </form>
</StandalonePageFrame> </StandalonePageFrame>
</div> </div>
+172 -20
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>; onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
@@ -53,6 +53,15 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -184,7 +193,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState(''); const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState(''); const [, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null); const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0); const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
@@ -193,6 +202,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +221,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +238,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -478,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(nextSettings); const saved = await props.onSaveSettings(masterPassword, payload);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved); setSavedSettings(saved);
@@ -507,11 +537,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
@@ -527,6 +563,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return; if (importing) return;
if (!selectedFile) { if (!selectedFile) {
@@ -547,8 +605,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -573,12 +631,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -613,6 +677,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return; if (!selectedDestination) return;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
@@ -621,7 +691,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +708,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message); setLocalError(message);
@@ -724,6 +799,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
@@ -738,8 +832,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
@@ -762,6 +856,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
try {
if (request.action === 'export') {
await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -848,7 +974,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}} }}
/> />
{localError ? <div className="local-error">{localError}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal(( {restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite"> <div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal"> <section className="restore-progress-card restore-progress-modal">
@@ -893,6 +1018,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal(( return createPortal((
<div <div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => { onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return; if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel(); props.onCancel();
}} }}
> >
+1
View File
@@ -91,6 +91,7 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'lastpass', 'lastpass',
'dashlane_csv', 'dashlane_csv',
'dashlane_json', 'dashlane_json',
'keepass_csv',
'keepass_xml', 'keepass_xml',
'keepassx_csv', '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,114 @@
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;
pendingAuthRequestsRefreshing?: 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);
const refreshing = props.pendingAuthRequestsLoading || !!props.pendingAuthRequestsRefreshing;
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={refreshing}
onClick={() => void props.onRefreshPendingAuthRequests()}
>
<RefreshCw size={14} className={`btn-icon${refreshing ? ' btn-icon-spin' : ''}`} />
{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>
);
}
+136 -41
View File
@@ -1,18 +1,28 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types'; import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps { interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
currentDeviceIdentifier: string;
loading: boolean; loading: boolean;
error: string; error: string;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
onRemoveAll: () => void; onRemoveAll: () => void;
} }
@@ -24,6 +34,12 @@ function formatDateTime(value: string | null | undefined): string {
return date.toLocaleString(); 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 { function mapDeviceTypeName(type: number): string {
switch (type) { switch (type) {
case 0: return t('txt_android'); case 0: return t('txt_android');
@@ -49,6 +65,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null); const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState(''); const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false); const [savingNote, setSavingNote] = useState(false);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const currentDeviceIdentifier = props.currentDeviceIdentifier;
const selectableDevices = props.devices.filter((device) => (
device.identifier !== currentDeviceIdentifier
));
const selectedDeviceIdSet = new Set(selectedDeviceIds);
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
async function handleSaveDeviceNote(): Promise<void> { async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return; if (!editingDevice || savingNote) return;
@@ -62,48 +86,100 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
} }
} }
function toggleSelectAllDevices(): void {
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
}
function toggleSelectedDevice(device: AuthorizedDevice): void {
if (device.identifier === currentDeviceIdentifier) return;
setSelectedDeviceIds((current) => (
current.includes(device.identifier)
? current.filter((id) => id !== device.identifier)
: [...current, device.identifier]
));
}
return ( return (
<> <>
<div className="stack"> <div className="stack">
<section className="card"> <PendingAuthRequestsPanel
<div className="section-head"> className="card"
<div> loadingVariant="compact"
<h3 className="flush-title">{t('txt_device_management')}</h3> pendingAuthRequests={props.pendingAuthRequests}
<div className="muted-inline section-note"> pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
</div> onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
</div> onApproveAuthRequest={props.onApproveAuthRequest}
<div className="actions"> onDenyAuthRequest={props.onDenyAuthRequest}
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}> />
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_all_devices')}
</button>
</div>
</div>
</section>
<section className="card"> <section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3> <div className="section-head">
{!!props.error && ( <div>
<div className="local-error"> <h3 className="flush-title">{t('txt_authorized_devices')}</h3>
<span>{props.error}</span> <div className="muted-inline section-note">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}> {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
<RefreshCw size={14} className="btn-icon" /> </div>
{t('txt_refresh')} </div>
</button> <div className="actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={props.loading || selectableDevices.length === 0}
onClick={toggleSelectAllDevices}
>
<CheckSquare size={14} className="btn-icon" />
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={selectedDevices.length === 0}
onClick={() => {
props.onRemoveSelectedDevices(selectedDevices);
setSelectedDeviceIds([]);
}}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_all_devices')}
</button>
</div>
</div> </div>
)} {!!props.error && (
<table className="table"> <div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<table className="table authorized-devices-table">
<colgroup>
<col className="authorized-devices-col-select" />
<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> <thead>
<tr> <tr>
<th>{t('txt_select')}</th>
<th>{t('txt_device')}</th> <th>{t('txt_device')}</th>
<th>{t('txt_type')}</th> <th>{t('txt_type')}</th>
<th>{t('txt_status')}</th> <th>{t('txt_status')}</th>
@@ -116,6 +192,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tbody> <tbody>
{props.devices.map((device) => ( {props.devices.map((device) => (
<tr key={device.identifier}> <tr key={device.identifier}>
<td data-label={t('txt_select')}>
<input
type="checkbox"
className="authorized-device-checkbox"
checked={selectedDeviceIdSet.has(device.identifier)}
disabled={device.identifier === currentDeviceIdentifier}
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
onChange={() => toggleSelectedDevice(device)}
/>
</td>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && ( {!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
@@ -135,14 +221,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
{device.trusted ? ( {device.trusted ? (
<div className="trusted-cell"> <div className="trusted-cell">
<Clock3 size={13} /> <Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span> <span>{isPermanentTrust(device.trustedUntil) ? t('txt_permanent_trust') : formatDateTime(device.trustedUntil)}</span>
</div> </div>
) : ( ) : (
<span className="muted-inline">{t('txt_not_trusted')}</span> <span className="muted-inline">{t('txt_not_trusted')}</span>
)} )}
</td> </td>
<td data-label={t('txt_actions')}> <td data-label={t('txt_actions')}>
<div className="actions"> <div className="actions authorized-devices-actions">
<button <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
@@ -152,6 +238,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_untrust')} {t('txt_untrust')}
</button> </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 <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
@@ -179,20 +274,20 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
))} ))}
{props.loading && props.devices.length === 0 && ( {props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<LoadingState lines={5} compact /> <LoadingState lines={5} compact />
</td> </td>
</tr> </tr>
)} )}
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div> <div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
+192 -21
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; 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 { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types'; import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
@@ -13,16 +13,29 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
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>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery'
| 'apiKey'
| 'rotateApiKey'
| 'createPasskey'
| 'enablePasskeyDirectUnlock'
| 'deletePasskey';
const LOCK_TIMEOUT_OPTIONS = [ const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' }, { value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' }, { value: 5, labelKey: 'txt_timeout_5_minutes' },
@@ -64,6 +77,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) { export default function SettingsPage(props: SettingsPageProps) {
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@@ -74,9 +94,14 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryCode, setRecoveryCode] = useState(''); const [recoveryCode, setRecoveryCode] = useState('');
const [apiKey, setApiKey] = 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 [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = 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 [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false); const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale()); const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
@@ -97,6 +122,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setPasswordHint(props.profile.masterPasswordHint || ''); setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]); }, [props.profile.masterPasswordHint]);
useEffect(() => {
void refreshAccountPasskeys();
}, [props.profile.id]);
const qrDataUrl = useMemo(() => { const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret)); qr.addData(buildOtpUri(props.profile.email, secret));
@@ -107,22 +136,35 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
if (totpLocked) return;
if (!secret.trim() || !token.trim()) {
props.onNotify?.('error', t('txt_secret_and_code_are_required'));
return;
}
openMasterPasswordPrompt('enableTotp');
}
async function refreshAccountPasskeys(): Promise<void> {
setAccountPasskeysLoading(true);
try { try {
await props.onEnableTotp(secret, token); setAccountPasskeys(await props.onListAccountPasskeys());
setTotpLocked(true); } catch (error) {
} catch { props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
// Keep inputs editable after a failed attempt. } finally {
setAccountPasskeysLoading(false);
} }
} }
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void { function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void {
setMasterPasswordPrompt(action); setMasterPasswordPrompt(action);
setAccountPasskeyPromptId(credentialId || null);
setMasterPasswordPromptValue(''); setMasterPasswordPromptValue('');
} }
function closeMasterPasswordPrompt(): void { function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return; if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null); setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue(''); setMasterPasswordPromptValue('');
} }
@@ -131,7 +173,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue; const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true); setMasterPasswordPromptSubmitting(true);
try { try {
if (masterPasswordPrompt === 'recovery') { if (masterPasswordPrompt === 'enableTotp') {
await props.onEnableTotp(secret, token, masterPassword);
setTotpLocked(true);
} else if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword); const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -139,13 +184,25 @@ export default function SettingsPage(props: SettingsPageProps) {
const key = await props.onGetApiKey(masterPassword); const key = await props.onGetApiKey(masterPassword);
setApiKey(key); setApiKey(key);
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
} else { } else if (masterPasswordPrompt === 'rotateApiKey') {
const key = await props.onRotateApiKey(masterPassword); const key = await props.onRotateApiKey(masterPassword);
setApiKey(key); setApiKey(key);
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated')); 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); setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue(''); setMasterPasswordPromptValue('');
} catch (error) { } catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2')); props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
@@ -155,17 +212,24 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
const masterPasswordPromptTitle = const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery' masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code') ? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey' : masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
: t('txt_view_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 { function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
if (!value) return t('txt_dash'); if (credential.prfStatus === 0) return t('txt_direct_unlock');
const parsed = new Date(value); if (credential.prfStatus === 1) return t('txt_login_only');
if (Number.isNaN(parsed.getTime())) return value; return t('txt_prf_not_supported');
return parsed.toLocaleString();
} }
async function changeLocale(next: Locale): Promise<void> { async function changeLocale(next: Locale): Promise<void> {
@@ -281,8 +345,15 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
<section className="card settings-module"> <section className="card settings-module">
<h3>{t('txt_totp')}</h3> <div className="settings-module-head">
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>} <h3>{t('txt_totp')}</h3>
{totpLocked && (
<span className="totp-status-pill">
<ShieldCheck size={14} aria-hidden="true" />
{t('txt_enabled')}
</span>
)}
</div>
<div className="totp-grid"> <div className="totp-grid">
<div className="totp-qr"> <div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" /> <img src={qrDataUrl} alt="TOTP QR" />
@@ -338,6 +409,106 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</section> </section>
<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>
<section className="settings-module sensitive-actions-module"> <section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid"> <div className="sensitive-actions-grid">
<div className="sensitive-action"> <div className="sensitive-action">
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps { interface StandalonePageFrameProps {
title: string; title: string;
eyebrow?: ComponentChildren; eyebrow?: ComponentChildren;
titleAccessory?: ComponentChildren;
children: ComponentChildren; children: ComponentChildren;
} }
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="auth-card"> <div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>} {props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<h1 className="standalone-title">{props.title}</h1> <div className="standalone-title-row">
<h1 className="standalone-title">{props.title}</h1>
{props.titleAccessory}
</div>
{props.children} {props.children}
</div> </div>
+1 -1
View File
@@ -7,7 +7,7 @@ interface ThemeSwitchProps {
export default function ThemeSwitch(props: ThemeSwitchProps) { export default function ThemeSwitch(props: ThemeSwitchProps) {
return ( return (
<div className="theme-switch-wrap" title={props.title}> <div className="theme-switch-wrap" title={props.title}>
<label className="theme-switch" aria-label={props.title}> <label className={`theme-switch ${props.checked ? 'checked' : 'unchecked'}`} aria-label={props.title}>
<span className="sun" aria-hidden="true"> <span className="sun" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="#ffd43b"> <g fill="#ffd43b">
+4 -2
View File
@@ -12,8 +12,10 @@ export default function ToastHost({ toasts, onClose }: ToastHostProps) {
{toasts.map((toast) => ( {toasts.map((toast) => (
<li key={toast.id} className={`toast-item ${toast.type}`}> <li key={toast.id} className={`toast-item ${toast.type}`}>
<div className="toast-text">{toast.text}</div> <div className="toast-text">{toast.text}</div>
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}> <button type="button" className="toast-close" onClick={() => onClose(toast.id)} aria-label="关闭通知">
x <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
</button> </button>
<div className="toast-progress" /> <div className="toast-progress" />
</li> </li>
+1 -8
View File
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import WebsiteIcon from '@/components/vault/WebsiteIcon'; 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 { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -26,13 +26,6 @@ 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 }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />; return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
} }
+98 -12
View File
@@ -17,13 +17,14 @@ import {
createEmptyDraft, createEmptyDraft,
creationTimeValue, creationTimeValue,
draftFromCipher, draftFromCipher,
buildCipherDuplicateSignature, buildCipherDuplicateSignatures,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -45,6 +46,7 @@ interface VaultPageProps {
onDelete: (cipher: Cipher) => Promise<void>; onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>; onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>; onUnarchive: (cipher: Cipher) => Promise<void>;
onRestore: (ids: string[]) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>; onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>; onBulkRestore: (ids: string[]) => Promise<void>;
@@ -78,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name'); const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -305,9 +308,10 @@ export default function VaultPage(props: VaultPageProps) {
const name = String(cipher.decName || cipher.name || ''); const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || ''); const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, { meta.set(cipher.id, {
name, name,
searchText: `${name}\n${username}\n${uri}`.toLowerCase(), searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(),
firstUri: uri, firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)), typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher), sortTime: sortTimeValue(cipher),
@@ -336,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => { const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null; if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>(); const byId = new Map<string, string[]>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signature); byId.set(cipher.id, signatures);
counts.set(signature, (counts.get(signature) || 0) + 1); for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
} }
return { byId, counts }; 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 filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
@@ -356,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { if (sidebarFilter.kind === 'duplicates') {
return false; 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 === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
@@ -402,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind; return sidebarFilter.kind;
}, [sidebarFilter]); }, [sidebarFilter, duplicateMode]);
useEffect(() => { useEffect(() => {
setListScrollTop(0); setListScrollTop(0);
@@ -417,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sidebarFilter.kind, sortMode]); }, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
@@ -714,6 +751,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setAttachmentQueue([]); setAttachmentQueue([]);
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail'); if (isMobileLayout) setMobilePanel('detail');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -727,6 +766,22 @@ const folderName = useCallback((id: string | null | undefined): string => {
setPendingDelete(null); setPendingDelete(null);
cancelEdit(); cancelEdit();
if (isMobileLayout) setMobilePanel('list'); 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -746,6 +801,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
} }
setSelectedMap({}); setSelectedMap({});
setBulkDeleteOpen(false); setBulkDeleteOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -762,6 +819,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkMove(ids, folderId); await props.onBulkMove(ids, folderId);
setSelectedMap({}); setSelectedMap({});
setMoveOpen(false); setMoveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -771,6 +830,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setBusy(true); setBusy(true);
try { try {
await props.onRefresh(); await props.onRefresh();
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -805,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onCreateFolder(newFolderName); await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false); setCreateFolderOpen(false);
setNewFolderName(''); setNewFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -819,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' }); setSidebarFilter({ kind: 'all' });
} }
setPendingDeleteFolder(null); setPendingDeleteFolder(null);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -836,6 +901,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onRenameFolder(pendingRenameFolder.id, nextName); await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null); setPendingRenameFolder(null);
setRenameFolderName(''); setRenameFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -850,6 +917,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try { try {
await props.onBulkRestore(ids); await props.onBulkRestore(ids);
setSelectedMap({}); setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -864,6 +933,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (isMobileLayout && selectedCipherId === pendingArchive.id) { if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list'); setMobilePanel('list');
} }
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -878,6 +949,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
delete next[cipher.id]; delete next[cipher.id];
return next; return next;
}); });
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -893,6 +966,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
await props.onBulkArchive(ids); await props.onBulkArchive(ids);
setSelectedMap({}); setSelectedMap({});
setBulkArchiveOpen(false); setBulkArchiveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -907,6 +982,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
try { try {
await props.onBulkUnarchive(ids); await props.onBulkUnarchive(ids);
setSelectedMap({}); setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -921,6 +998,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
setSidebarFilter({ kind: 'all' }); setSidebarFilter({ kind: 'all' });
} }
setDeleteAllFoldersOpen(false); setDeleteAllFoldersOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -940,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => { const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const seen = new Set<string>(); const seen = new Set<string>();
for (const cipher of filteredCiphers) { 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)) { if (seen.has(signature)) {
map[cipher.id] = true; map[cipher.id] = true;
continue; continue;
@@ -951,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature); seen.add(signature);
} }
setSelectedMap(map); setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]); }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true; for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1035,13 +1115,16 @@ const folderName = useCallback((id: string | null | undefined): string => {
busy={busy} busy={busy}
loading={props.loading} loading={props.loading}
error={props.error} error={props.error}
folders={props.folders}
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount} selectedCount={selectedCount}
totalCipherCount={totalCipherCount} totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers} filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers} visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange} virtualRange={virtualRange}
selectedCipherId={selectedCipherId} selectedCipherId={selectedCipherId}
selectedMap={selectedMap} selectedMap={selectedMap}
@@ -1058,6 +1141,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd} onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu} onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode} onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onChangeFilter={setSidebarFilter}
onSyncVault={handleSyncVault} onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete} onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates} onSelectDuplicates={handleSelectDuplicates}
@@ -1148,6 +1233,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit} onStartEdit={startEdit}
onDelete={setPendingDelete} onDelete={setPendingDelete}
onRestore={(cipher) => void handleRestoreSelected(cipher)}
onArchive={(cipher) => setPendingArchive(cipher)} onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)} onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/> />

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