Compare commits

..
148 Commits
Author SHA1 Message Date
dependabot[bot]andShuai 8d399f431b chore(deps-dev): bump vite from 7.3.5 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:53:06 +08:00
shuaiplus bb3f866220 feat: add audit log demo data and passkey/API stub overrides 2026-07-09 20:51:12 +08:00
shuaiplusandShuai 39d9df78ea feat: add functionality to select unique items from duplicates in Vault components 2026-07-09 20:21:45 +08:00
rootphantomerandShuai a1b12fc447 feat: add duplicate detection demo data to dev:demo mode 2026-07-09 20:21:45 +08:00
rootphantomerandShuai 099217062a fix: group duplicates by color and sort A-Z within groups 2026-07-09 20:21:45 +08:00
DomainmasteriandGitHub dd90d7b8b8 Add Finnish (fi) translation (#299)
* Add Finnish (fi) locale file for nodewarden web vault translation
2026-07-09 19:00:33 +08:00
rootphantomerandShuai 525b773cf4 fix: preserve dialog title during closing animation to prevent flash 2026-07-09 18:53:01 +08:00
shuaiplus 04cb475935 feat: enhance date formatting in PublicSendPage and SendsPage components 2026-07-08 19:31:11 +08:00
ShuaiandGitHub e063f45cd9 Update FUNDING.yml 2026-07-08 11:59:17 +08:00
ShuaiandGitHub e10920d142 Create FUNDING.yml 2026-07-07 19:55:15 +08:00
shuaiplus b07edb0850 fix: correct directory name in .gitignore and remove nodewarden-wiki subproject 2026-07-07 19:48:40 +08:00
shuaiplus 58a86ae8fd docs: update feature comparison table for clarity and consistency 2026-07-07 18:36:35 +08:00
shuaiplus b986af86dc docs: add Fill-assist row to feature comparison table
Align English README with README_ZH for POST /fill-assist client support.
2026-07-07 14:18:19 +08:00
shuaiplus 8e33f92b33 docs: make English the default README, move Chinese to README_ZH
- README.md is now the English version (GitHub default)
- Add README_ZH.md for Chinese documentation
- Remove redundant README_EN.md
2026-07-07 13:53:11 +08:00
shuaiplus a0f832e8a5 chore: release v1.7.3 2026-07-07 01:43:14 +08:00
shuaiplus 8a5b210a1d Improve Bitwarden-compatible TOTP handling 2026-07-07 01:27:55 +08:00
shuaiplus ebc8e8e340 Replace remove button with trash icon positioned inside password field 2026-07-07 00:35:16 +08:00
shuaiplus a870142b7b Show password indicator and masked password field for encrypted sends
- Add password and authType fields to Send type
- Show lock icon in send list for password-protected sends
- Display masked dots in password field when editing a send that has a password set
- Add Remove button to clear existing password
2026-07-07 00:31:56 +08:00
shuaiplus a366acbac0 Revert redirect guard in backup uploader 2026-07-06 23:54:17 +08:00
shuaiplus 57c5ef9da6 Remove WEBSITE_ICONS_ENABLED and always enable website icons 2026-07-06 22:56:36 +08:00
shuaiplus f532d3ace3 Preserve WebAuthn credential purpose in backups 2026-07-06 22:25:02 +08:00
shuaiplus cc4a830be8 Harden backup and download token flows 2026-07-06 19:06:24 +08:00
shuaiplus c6438747e3 Harden 2FA disable and website icon privacy 2026-07-06 18:45:54 +08:00
shuaiplus 5c8f01be59 Harden backup blob and remote endpoint handling 2026-07-06 18:17:55 +08:00
shuaiplus 7ac6ae50bb Harden auth requests and backup endpoints 2026-07-06 18:04:23 +08:00
shuaiplus ace00e8e74 Harden WebAuthn extension origins 2026-07-06 17:43:57 +08:00
shuaiplus 51428461a8 fix: cap multipart backup and upload requests 2026-07-06 15:38:59 +08:00
shuaiplus 23c53bd1af fix: validate anonymous notification hub requests 2026-07-06 15:38:52 +08:00
shuaiplus ae168bea31 fix rate limit reset bypasses 2026-07-06 14:24:01 +08:00
shuaiplus 00e0ec0892 fix(backup): redact destination secrets in settings 2026-07-06 13:50:49 +08:00
shuaiplus 2df43ccdb0 fix(auth): revoke current access token session 2026-07-06 13:49:06 +08:00
shuaiplus fd46dffc34 fix: align push relay installation requests 2026-07-06 02:09:35 +08:00
shuaiplus 56b301f2d1 fix: align fill assist compatibility 2026-07-06 02:09:28 +08:00
shuaiplus f0e523376c fix: add admin auth request compatibility 2026-07-06 01:35:26 +08:00
shuaiplus 8b2f98b847 fix: add Bitwarden device registration endpoints 2026-07-06 01:35:19 +08:00
shuaiplus cde4555add fix: return unsupported for email and kdf flows 2026-07-06 01:35:12 +08:00
shuaiplus 1bad32fd90 fix: keep remembered 2fa token on bad password 2026-07-06 01:07:26 +08:00
shuaiplus e376a840c2 fix: add device verification settings endpoints 2026-07-06 00:59:34 +08:00
shuaiplus 9de0d3bd87 fix: clarify extended cipher type icons 2026-07-06 00:56:57 +08:00
shuaiplus 109593da90 feat: support Bitwarden extended cipher types 2026-07-06 00:44:17 +08:00
shuaiplus 01ff627ac6 fix(i18n): localize audit logs and new validation messages 2026-07-05 23:45:26 +08:00
shuaiplus d028b194e7 fix(admin): record audit clears and passkey 2fa status 2026-07-05 23:45:18 +08:00
shuaiplus c53d71fc28 feat(settings): move device management into settings 2026-07-05 23:44:54 +08:00
shuaiplus 6e722205b1 fix(totp): validate qr image uploads 2026-07-05 23:44:44 +08:00
shuaiplus cf14704d99 fix(import): validate payloads and zip entries 2026-07-05 23:44:36 +08:00
shuaiplus 0cef6a04e9 fix(backup): verify remote deletes and validate archives 2026-07-05 23:44:25 +08:00
shuaiplus 8c481a1564 fix(send): refresh routes and gate file access 2026-07-05 23:44:07 +08:00
shuaiplus d9a36fefe6 fix(security): harden auth and request limits 2026-07-05 23:43:49 +08:00
shuaiplus 12af18e3a3 feat: update device management link handling in navigation 2026-07-05 15:41:27 +08:00
shuaiplus d8cc88d9c0 feat: add UUID normalization functions and enhance WebAuthn response handling 2026-07-05 15:37:02 +08:00
shuaiplus 94b5f3e975 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-05 15:17:00 +08:00
shuaiplus 062c966e14 feat: update styles for two-step providers and responsive layout adjustments 2026-07-05 15:16:56 +08:00
shuaiplus e73ae3d5ea feat: enhance two-factor authentication handling and UI improvements 2026-07-05 15:05:53 +08:00
shuaiplus c019c93726 feat: add passkey-based two-factor authentication 2026-07-05 14:51:11 +08:00
shuaiplus f63b745d05 feat: Add YubiKey OTP support and management features
- Implemented YubiKey OTP settings management in useAccountSecurityActions hook.
- Added API functions for retrieving, saving, and bootstrapping YubiKey OTP credentials.
- Enhanced authentication flow to support multiple two-factor providers, including YubiKey.
- Updated localization files to include new YubiKey-related strings in English, Spanish, Russian, and Chinese.
- Introduced new styles for YubiKey management UI components.
- Created utility functions for YubiKey OTP validation and credential handling.
2026-07-04 02:49:46 +08:00
shuaiplus c7eb6c663d feat(i18n): add new localization strings for settings and two-step login across multiple languages
style: adjust grid layout for app main and add responsive styles for settings category

style: enhance management styles with new settings category layout and tabs

style: improve responsive design for settings modules and submodules
2026-07-03 19:19:24 +08:00
rootphantomerandShuai 1ec6ed44a1 fix: reject plaintext FIDO2, SSH keys, and password history on import
Validate encrypted-string fields in validateCipherEncryptedFieldsForCompatibility
before they reach storage:

- FIDO2 credentials (12 fields: 8 required + 4 optional)
- SSH key (privateKey, publicKey, keyFingerprint/fingerprint)
- Password history (password per entry)

This closes a defense gap where plaintext in these positions was silently
accepted on import and later discarded at response time.
2026-07-02 17:36:06 +08:00
shuaiplus 6284c632de Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-02 17:23:47 +08:00
shuaiplus 60dd298dee fix(security): harden jwt config and password rotation 2026-07-02 17:20:51 +08:00
shuaiplus 439683d350 fix(identity): add security stamp and invalidate user cache on token handling 2026-07-02 16:57:24 +08:00
shuaiplus 1545881eae fix(auth): hash stored api keys 2026-07-02 16:27:20 +08:00
shuaiplus 680e287c8d fix(ci): validate global domains sync ref 2026-07-02 16:11:19 +08:00
shuaiplus baf569983d fix(security): scope storage reads by user 2026-07-02 16:03:49 +08:00
Matt Van HornandShuai 73bbe8b268 perf: throttle jsQR camera fallback to a few decodes per second 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai d024798548 fix: composite transparent QR uploads over white before jsQR decode 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai b0a679b1c2 fix: decode uploaded TOTP QR images when BarcodeDetector is unavailable
The TOTP QR reader relied solely on window.BarcodeDetector. On desktop
Chrome/Edge (Windows/Linux) that interface exists but has no working
backend, so detect() returns an empty array: uploading a valid QR image
fell through to "no QR code found" and the camera path bailed to
"unsupported" with an empty preview.

Add a dependency-free jsQR canvas fallback. decodeTotpQrImage now tries
BarcodeDetector first when present, then decodes the image via jsQR
before reporting not-found. The camera reader no longer hard-returns
"unsupported" when only BarcodeDetector is missing: it starts the camera
whenever getUserMedia is available and decodes frames with jsQR, which
also lets the preview render.

Fixes #276
2026-07-01 17:07:30 +08:00
shuaiplus ce3674669e feat: update version to 1.7.2 in package.json, package-lock.json, and app-version.ts 2026-07-01 13:36:59 +08:00
shuaiplus aa7b87e041 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-01 13:28:26 +08:00
shuaiplus e4215b4025 feat: add fill-assist handlers and update device response type 2026-07-01 13:28:22 +08:00
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
167 changed files with 27124 additions and 5090 deletions
+5 -17
View File
@@ -1,17 +1,5 @@
# CodeGraph data files # CodeGraph data files — local to each machine, not for committing.
# These are local to each machine and should not be committed # Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
# Database *
*.db !.gitignore
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
*.pid
-5
View File
@@ -1,5 +0,0 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://nodewarden.app/sponsor
+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
+13 -4
View File
@@ -19,20 +19,29 @@ jobs:
sync-global-domains: sync-global-domains:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with: with:
node-version: 22 node-version: 22
- name: Sync generated Bitwarden domains - name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}" env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched - name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request - name: Create pull request
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with: with:
branch: chore/sync-bitwarden-global-domains branch: chore/sync-bitwarden-global-domains
delete-branch: true delete-branch: true
-143
View File
@@ -1,143 +0,0 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
echo "mode=auto" >> $GITHUB_OUTPUT
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
fi
+10 -1
View File
@@ -18,6 +18,7 @@ build/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
docs/
# OS # OS
.DS_Store .DS_Store
@@ -42,7 +43,7 @@ tmp/
.tmp/ .tmp/
.tmp-bitwarden-clients/ .tmp-bitwarden-clients/
nodewarden.wiki/ nodewarden-wiki/
wiki/ wiki/
AGENTS.md AGENTS.md
settings.json settings.json
@@ -55,3 +56,11 @@ NodeWarden-compat/
.codex-upstream/bitwarden-browser/ .codex-upstream/bitwarden-browser/
.reasonix/ .reasonix/
.upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json
opencode.jsonc
.cursor/
+63 -109
View File
@@ -3,95 +3,100 @@
</p> </p>
<p align="center"> <p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端 Bitwarden-compatible server running on Cloudflare Workers
</p> </p>
<p align="center"> <p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a> <a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a> <a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> | <a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a> <a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p> </p>
<p align="center"> <p align="center">
<a href="./README_EN.md">English</a> | <a href="./README_ZH.md">中文</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> <a href="./CONTRIBUTING.md">Contributing</a> |
<a href="https://nodewarden.app">Official wiki</a>
</p> </p>
> **免责声明** > **Disclaimer**
> 本项目仅供学习与交流使用,请定期备份你的密码库。 > This project is for learning and discussion purposes only. Please back up your vault regularly.
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。 > This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
--- ---
## Bitwarden 官方服务端能力对比 ## Feature comparison with the official Bitwarden server
| 能力 | Bitwarden | NodeWarden | 说明 | | Feature | Bitwarden Free | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** | | Web vault | ✅ | ✅ | **Original Web Vault UI** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | TOTP | ❌ | ✅ | Includes `steam://` support |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **PWA / offline** | ❌ | ✅ | **Installable, offline** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | | **Passkey login** | ✅ | ✅ | **passwordless auth** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | | API keys | ✅ | ✅ | CLI keys; create and rotate |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
| Send | ✅ | ✅ | 支持文本与文件 Send | | 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | Real-time push sync | ✅ | ✅ | All device sync |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** | | Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** | | Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
| TOTP / Steam TOTP | | ✅ | `steam://` 支持 | | **Cloud backup center** | | ✅ | **Scheduled WebDAV / S3 incrementals** |
| 多用户 | ✅ | ✅ | 支持邀请码注册 | | Device management | ✅ | ✅ | **Remove devices; trust controls** |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 | | Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) | | **Multi-user** | ✅ | ✅ | Invite-code registration |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 | | Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
--- ---
## 已测试客户端 ## Tested clients
- ✅ Windows 桌面端 - ✅ Windows desktop
-手机 App -Mobile app
-浏览器扩展 -Browser extension
- ✅ Linux 桌面端 - ✅ Linux desktop
- ⚠️ macOS 桌面端尚未完整验证 - ⚠️ macOS desktop not fully verified yet
--- ---
## 可视化快速部署 ## Visual quick deploy
1. Fork NodeWarden 仓库到自己的 GitHub 账号 1. Fork the NodeWarden repository to your GitHub account
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) 2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库 3. Choose **Continue with GitHub** and select your fork
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy` 4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv` - For KV mode, change the deploy command to `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名 5. After deployment finishes, open the generated Workers URL
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。 - The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。 - If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。 - In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
> [!TIP] > [!TIP]
> 默认R2与可选KV的区别: > Default R2 vs optional KV:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 | > | Storage | Card required | Max single attachment / Send file | Free tier |
> |---|---|---|---| > |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB | > | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB | > | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
## 更新方法: ## How to update
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。 - Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork****Update branch**
## CLI 部署
## CLI deploy
```powershell ```powershell
git clone https://github.com/shuaiplus/NodeWarden.git git clone https://github.com/shuaiplus/NodeWarden.git
@@ -100,82 +105,31 @@ cd NodeWarden
npm install npm install
npx wrangler login npx wrangler login
# 默认:R2 模式 # Default: R2 mode
npm run deploy npm run deploy
# 可选:KV 模式 # Optional: KV mode
npm run deploy:kv npm run deploy:kv
# 本地开发 # Local development
npm run dev npm run dev
npm run dev:kv npm run dev:kv
``` ```
--- ---
## 主要特性
### PWA 渐进式 Web 应用 ## License
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
LGPL-3.0 License LGPL-3.0 License
--- ---
## 致谢 ## Credits
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端 - [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考 - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 - [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
--- ---
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Key Features
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+137
View File
@@ -0,0 +1,137 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
## CLI 部署
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## 开源协议
LGPL-3.0 License
---
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+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.
-785
View File
@@ -1,785 +0,0 @@
# NodeWarden Passkey 登录研究记录
记录日期:2026-06-09
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
## 结论先放前面
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
下面按代码链路展开。
## 术语边界
这里有三个容易混淆的东西,文档后面严格区分:
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
## NodeWarden 现状
### 路由和入口
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token``/identity/accounts/prelogin``/api/accounts/register`
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
- `src/handlers/identity.ts`OAuth/token 兼容入口。
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
目前公开路由没有:
- `GET /identity/accounts/webauthn/assertion-options`
- `POST /identity/connect/token``grant_type=webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `GET/POST/PUT /api/webauthn`
### 注册链路
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts``registerAccount()`
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
- 随机生成 64 字节 vault symmetric key。
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
- POST `/api/accounts/register`,提交 `email``name``masterPasswordHash``key`、KDF 参数、invite code、`keys.publicKey``keys.encryptedPrivateKey`
后端 `src/handlers/accounts.ts``handleRegister()`
- 第一个用户自动成为 admin,后续用户需要 invite。
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`
- 保存 `users.key``users.private_key``users.public_key`、KDF 参数、`security_stamp`
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
### 主密码登录链路
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts``performPasswordLogin()`
-`deriveLoginHashLocally()` 得到 masterKey 和 client hash。
-`loginWithPassword()` POST `/identity/connect/token`
- token 成功后 `completeLogin()``token.Key` 和本地 masterKey 解开 vault key。
- 保存离线解锁记录。
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()``getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
后端 `src/handlers/identity.ts``handleToken()` 当前支持:
- `grant_type=password`
- `grant_type=client_credentials`
- `grant_type=refresh_token`
密码登录成功后会:
- 验证 IP 登录频率和用户状态。
- `AuthService.verifyPassword()` 验证 client hash。
- 处理 TOTP 或 remember 2FA token。
- 记录/更新 device。
- 生成 access token 和 refresh token。
- 返回 `Key``PrivateKey``AccountKeys`、KDF 参数、`UserDecryptionOptions`
### UserDecryptionOptions 和 sync
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
- `HasMasterPassword: true`
- `MasterPasswordUnlock`
- `TrustedDeviceOption: null`
- `KeyConnectorOption: null`
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
passkey 登录必须新增两类 shape
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
### 现有 passkey 相关代码
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
- `src/types/index.ts``CipherLogin.fido2Credentials`
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
- `webapp/src/lib/types.ts``CipherLoginPasskey`
这部分是“保存网站 passkey”,不是账户登录。
`src/utils/passkey.ts` 只有:
- `bytesToBase64Url()`
- `base64UrlToBytes()`
- `randomChallenge()`
- `parseClientDataJSON()`
缺少的核心能力:
- attestation verification
- assertion verification
- authenticator public key 格式处理
- signature verification
- sign counter 更新
- userHandle 与 user id 绑定验证
- origin/RP ID 验证
- challenge 过期和防重放
### 数据库和备份影响
NodeWarden schema 在这些地方需要同步:
- `migrations/0001_init.sql`
- `src/services/storage-schema.ts`
- `wrangler.toml` migrations
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `shared/backup-schema` 相关类型
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
## 官方 Bitwarden server 参考
上游代码位置:
- `.codex-upstream/bitwarden-server`
- 研究时 HEAD`574f3fd`
官方 server 里也有两个 WebAuthn 概念:
- 传统 WebAuthn 2FA`TwoFactorController``WebAuthnTokenProvider`
- 账户 passkey 登录:`WebAuthnLogin`
本项目要参考的是后者。
### 公开 passkey 登录入口
`src/Identity/Controllers/AccountsController.cs`
- `GET /accounts/webauthn/assertion-options`
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
- response 包含:
- `options`
- `token`
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
- scope 为 `Authentication`
- token 生命周期约 17 分钟
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- 新增 OAuth extension grant`grant_type=webauthn`
- 从 form 读取:
- `token`
- `deviceResponse`
- 解开 token,校验 scope 必须是 `Authentication`
- 反序列化 `AuthenticatorAssertionRawResponse`
- 调用 `AssertWebAuthnLoginCredential`
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
`src/Identity/IdentityServer/ApiClient.cs`
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
### 账户 passkey 管理接口
`src/Api/Auth/Controllers/WebAuthnController.cs`
官方 authenticated API
- `GET /webauthn`:列出账户 passkey credentials。
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
- `POST /webauthn`:保存新 credential。
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
- `POST /webauthn/{id}/delete`:删除 credential。
官方创建 credential 时保存:
- `name`
- `token`
- `deviceResponse`
- `supportsPrf`
- 可选 `encryptedUserKey`
- 可选 `encryptedPublicKey`
- 可选 `encryptedPrivateKey`
官方最多允许 5 个账户 passkey credentials。
### 官方 WebAuthnCredential 表
`src/Core/Auth/Entities/WebAuthnCredential.cs`
字段:
- `Id`
- `UserId`
- `Name`
- `PublicKey`
- `CredentialId`
- `Counter`
- `Type`
- `AaGuid`
- `EncryptedUserKey`
- `EncryptedPrivateKey`
- `EncryptedPublicKey`
- `SupportsPrf`
- `CreationDate`
- `RevisionDate`
SQLite migration`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
`GetPrfStatus()`
- `Unsupported``SupportsPrf` 为 false。
- `Supported`credential 支持 PRF,但还没有完整 encrypted keyset。
- `Enabled``EncryptedUserKey``EncryptedPrivateKey``EncryptedPublicKey` 都存在。
### 官方创建和认证策略
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- 使用 Fido2NetLib。
- `user.id` 是用户 id bytes。
- `user.name/displayName` 使用用户邮箱。
- 排除当前用户已有 credential ids。
- `residentKey: required`
- `userVerification: required`
- `attestation: none`
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `allowCredentials` 传空数组。
- `userVerification: required`
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
`CreateWebAuthnLoginCredentialCommand.cs`
- 限制每用户最多 5 个。
- 检查 credential id 在该用户下不能重复。
- FIDO `MakeNewCredentialAsync` 验证 attestation。
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
`AssertWebAuthnLoginCredentialCommand.cs`
- 先用 challenge cache 防重放。
- 从 assertion response 的 `userHandle` 解析出 user id。
- 加载该用户所有 WebAuthn credentials。
- 用 credential id 找到记录。
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
- 成功后更新 counter。
### 官方 PRF 解密协议
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
`WebAuthnPrfDecryptionOption` 字段:
- `EncryptedPrivateKey`
- `EncryptedUserKey`
- `CredentialId`
- `Transports`
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`
- 如果 credential 没有 PRF keysetpasskey 只能认证账号,不能解开 vault。
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`
## 官方 Bitwarden web/browser client 参考
上游代码位置:
- `.codex-upstream/bitwarden-clients`
- `.codex-upstream/bitwarden-browser`
- 两者研究时 HEAD 都是 `825f9be`browser repo 内容和 clients monorepo 对应。
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
### 登录按钮可见性
`libs/auth/src/angular/login/default-login-component.service.ts`
- 默认只对 `ClientType.Web` 开启 passkey 登录。
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- browser extension 覆盖逻辑:只对 Chromium 开启。
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
### Passkey 登录页
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
流程:
1. 进入 `/login-with-passkey` 后自动开始认证。
2.`webAuthnLoginService.getCredentialAssertionOptions()`
3.`webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`
4.`webAuthnLoginService.logIn(assertion)` 走 identity token grant。
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
7. 成功路由:
- Web`/vault`
- Browser`/tabs/vault`
- Desktop`/vault`
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
### 客户端 passkey 登录请求
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- GET `${identityUrl}/accounts/webauthn/assertion-options`
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `navigator.credentials.get({ publicKey: options })`
- 会主动加 PRF extension
- salt 是 `SHA-256("passwordless-login")`
- extension shape 是 `extensions.prf.eval.first`
-`credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
-`WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
- 构造 `WebAuthnLoginAssertionResponseRequest`
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- salt 常量:`passwordless-login`
- 先 SHA-256。
- 再用 HKDF expand 拆成 64 字节:
- `"enc"` 32 bytes
- `"mac"` 32 bytes
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
form encoded token 请求字段:
- `grant_type=webauthn`
- `token=<server assertion options token>`
- `deviceResponse=<JSON string>`
- 还会带 common device request 字段。
`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
`deviceResponse` shape
- `id`
- `rawId`
- `type`
- `extensions: {}`
- `response.authenticatorData`
- `response.signature`
- `response.clientDataJSON`
- `response.userHandle`
全部二进制字段使用 base64url。
### 客户端如何用 PRF 解 vault key
`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。
- `setUserKey()`
- 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。
- 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`
1. 用 PRF key unwrap `encryptedPrivateKey`
2. 用 private key decapsulate `encryptedUserKey`
3. 得到 user key,写入 `keyService`
核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。
### 官方 web 设置页注册 passkey
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
调用的 API
- `POST /webauthn/attestation-options`
- `POST /webauthn/assertion-options`
- `POST /webauthn`
- `GET /webauthn`
- `POST /webauthn/{id}/delete`
- `PUT /webauthn`
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
创建流程:
1. 用户做 secret verification。
2. 请求 attestation options。
3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`
4. 从 client extension results 判断 `supportsPrf`
5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`
- `allowCredentials` 锁定刚创建的 credential。
- 使用同一个 challenge、rpId、timeout、userVerification。
- 带 PRF eval salt。
6. 用 PRF key 和当前 user key 创建 rotateable keyset。
7. 保存 credential,带上 `encryptedUserKey``encryptedPublicKey``encryptedPrivateKey`
删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。
`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `Enabled = 0`
- `Supported = 1`
- `Unsupported = 2`
## NodeWarden 应实现的协议形状
### 公开登录流程
目标兼容官方客户端和 NodeWarden 自己 web
1. `GET /identity/accounts/webauthn/assertion-options`
- 生成 discoverable credential assertion options。
- `allowCredentials: []`
- `userVerification: "required"`
- 返回 `{ options, token }`
- token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。
2. Browser/web 调 `navigator.credentials.get()`
- NodeWarden 自己 web 也要使用 PRF extension。
- PRF salt 必须和官方一致:`SHA-256("passwordless-login")`
3. `POST /identity/connect/token`
- 支持 `grant_type=webauthn`
- 接收 `token``deviceResponse`、device fields。
- 解 token,校验 challenge/scope/过期。
- 验证 assertion。
-`userHandle` 找到 user id。
- 从 credential id 找到 passkey record。
- 更新 counter。
- 记录/更新 device。
- 返回 access/refresh token、`AccountKeys``UserDecryptionOptions.WebAuthnPrfOption`
如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwardenpasskey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
### 账户 passkey 管理流程
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`
- `GET /api/webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `POST /api/webauthn`
- `PUT /api/webauthn`
- `POST /api/webauthn/:id/delete`
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases
- `/webauthn`
- `/webauthn/attestation-options`
- `/webauthn/assertion-options`
- `/webauthn/:id/delete`
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`
### 建议新增表
按 NodeWarden 命名风格,建议用小写 snake_case
```sql
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential
ON webauthn_credentials(user_id, credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
```
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
PRF status 不必落库为枚举,可以由字段计算:
- `supports_prf = 0` => `Unsupported`
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
### Challenge/token 存储
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
- tokenHMAC/JWT 样式,绑定 `scope``challenge``userId?``rpId``createdAt``expiresAt`
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash``scope``user_id``expires_at``used_at`
- 登录 assertion options 是公开接口,不绑定 user idcreate/update/delete 管理流程应绑定 user id。
- 验证成功后立即 mark used。
建议 scopes
- `Authentication`
- `CreateCredential`
- `UpdateKeySet`
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
### 服务端 WebAuthn 验证库
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions``verifyRegistrationResponse``generateAuthenticationOptions``verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
## NodeWarden web 需要改的地方
### 登录页
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx``webapp/src/lib/app-auth.ts` 管。
新增:
- 登录页增加“使用 passkey 登录”按钮。
- 新增 `performPasskeyLogin()`
1. GET `/identity/accounts/webauthn/assertion-options`
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
3. `navigator.credentials.get()`,带 PRF salt。
4. POST `/identity/connect/token``grant_type=webauthn`
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
6. 用本地 PRF key 解出 user key。
7. 构造 `SessionState` 并进入 app。
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
### 设置页
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
新增:
- Passkey 列表。
- 新建 passkey dialog。
- 删除 passkey。
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
自己 web 的新建流程要和官方一致:
1. 已登录状态下先验证主密码或现有 session secret。
2. 请求 attestation options。
3. `navigator.credentials.create()``extensions.prf = {}`
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
### 客户端加密能力
NodeWarden web 当前已经有:
- PBKDF2
- HKDF expand
- Bitwarden EncString 加解密
- RSA-OAEP private key 加密
但 passkey PRF keyset 需要和官方策略对齐:
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
- `encryptedUserKey` 用对应 public key encapsulate user key。
- `encryptedPublicKey` 用于 key rotation。
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
## 扩展兼容要求
### 官方 browser extension
官方 extension passkey 登录入口在:
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- 只在 Chromium 开启。
如果要官方/派生扩展能对 NodeWarden passkey 登录:
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`
- token URL 必须支持 `grant_type=webauthn`
- API URL 必须能访问 `/webauthn` 管理接口。
- response 大小写和字段名要同时照顾 PascalCase/camelCaseNodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
### RP ID 和 origin
自己的 web
- RP ID 通常是站点 host,例如 `vault.example.com`
- origin 是 `https://vault.example.com`
官方 browser extension
- 扩展页面 origin 是 `chrome-extension://...`
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
建议配置化:
- `WEBAUTHN_RP_ID`
- `WEBAUTHN_RP_NAME`
- `WEBAUTHN_ALLOWED_ORIGINS`
默认可以从 request URL 推导 web origin,但生产建议显式配置。
## 安全约束
- 所有账户 passkey 必须 `userVerification: required`
- 登录 assertion 使用 discoverable credential`userHandle` 必须能解析成 user id 并和 credential 记录一致。
- challenge 必须有过期时间和一次性使用标记。
- PRF 输出绝不能传给 server,也不能写入日志。
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
- 每用户 credential 数量限制建议沿用官方 5 个。
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
- 审计日志建议新增:
- `auth.passkey.login.success`
- `auth.passkey.login.failed`
- `account.passkey.create`
- `account.passkey.delete`
- `account.passkey.encryption.enable`
- `account.passkey.rotate`
## 建议实施顺序
### 第一阶段:后端基础
1. 新增 `webauthn_credentials` 和 challenge 表。
2. 新增 storage repo。
3. 接入 WebAuthn 服务端验证库。
4. 实现 assertion options 和 `grant_type=webauthn`
5. token response 加 `WebAuthnPrfOption` shape。
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
### 第二阶段:账户 passkey 管理 API
1. 实现 `/api/webauthn``/webauthn` aliases。
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
3. 加 audit event。
4. 接入 backup export/import。
5. sync response 加 `WebAuthnPrfOptions`
### 第三阶段:NodeWarden 自己 web
1. 登录页 passkey 按钮和 `performPasskeyLogin()`
2. Passkey 设置页。
3. PRF keyset 创建、保存、删除、启用 encryption。
4. 浏览器能力判断和错误提示。
### 第四阶段:扩展兼容
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
2. 校对 `/config` 里 identity/api/web vault URL。
3. 校对 RP ID、allowed origins。
4. 必要时加兼容字段或 alias route。
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
## 待实现清单
- [ ] 设计并落库 `webauthn_credentials`
- [ ] 设计并落库 WebAuthn challenge/replay cache。
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
- [ ] `GET /identity/accounts/webauthn/assertion-options`
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`
- [ ] `UserDecryption.WebAuthnPrfOptions`
- [ ] `/api/webauthn` 管理接口。
- [ ] `/webauthn` 官方客户端 alias。
- [ ] NodeWarden web passkey 登录入口。
- [ ] NodeWarden web passkey 管理页。
- [ ] key rotation 时同步 rotate PRF keysets。
- [ ] backup export/import 覆盖新表。
- [ ] audit logs 覆盖 passkey 管理和登录。
## 关键文件索引
NodeWarden
- `src/router-public.ts`
- `src/router-authenticated.ts`
- `src/handlers/accounts.ts`
- `src/handlers/identity.ts`
- `src/handlers/sync.ts`
- `src/services/auth.ts`
- `src/services/storage-schema.ts`
- `src/services/storage-user-repo.ts`
- `src/services/storage-device-repo.ts`
- `src/utils/passkey.ts`
- `src/utils/user-decryption.ts`
- `src/types/index.ts`
- `webapp/src/lib/api/auth.ts`
- `webapp/src/lib/app-auth.ts`
- `webapp/src/components/AuthViews.tsx`
- `webapp/src/components/SettingsPage.tsx`
Bitwarden server
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
Bitwarden clients/browser
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
+41
View File
@@ -176,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,
@@ -187,6 +189,34 @@ CREATE TABLE IF NOT EXISTS devices (
); );
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_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,
@@ -198,9 +228,20 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'login',
name TEXT NOT NULL, name TEXT NOT NULL,
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
credential_id TEXT NOT NULL, credential_id TEXT NOT NULL,
+1458 -966
View File
File diff suppressed because it is too large Load Diff
+27 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.6.0", "version": "1.7.3",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -42,28 +42,35 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.5",
"@types/node": "^25.2.3", "@types/node": "^26.0.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.5.2",
"opencc-js": "^1.0.5", "opencc-js": "^1.3.2",
"postcss": "^8.5.6", "postcss": "^8.5.16",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"tsx": "^4.21.0", "tsx": "^4.22.4",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"vite": "^7.3.1", "vite": "^8.1.3",
"wrangler": "^4.71.0" "wrangler": "^4.105.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.1", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.22", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.2", "fflate": "^0.8.3",
"lucide-preact": "^0.575.0", "jsqr": "1.4.0",
"preact": "^10.28.4", "lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.9.0" "wouter": "^3.10.0"
} }
} }
+1
View File
@@ -13,6 +13,7 @@ const localeFiles = [
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'], ['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'], ['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'], ['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'],
]; ];
function readLocale(fileName, variableName) { function readLocale(fileName, variableName) {
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.6.0'; export const APP_VERSION = '1.7.3';
+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,
}; };
} }
+3
View File
@@ -62,6 +62,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5,
// Fixed window size for API rate limiting in seconds. // Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。 // API 限流固定窗口大小(秒)。
apiWindowSeconds: 60, apiWindowSeconds: 60,
+16 -5
View File
@@ -19,7 +19,7 @@ import {
executeConfiguredBackup, executeConfiguredBackup,
importAndAuditRemoteBackupFile, importAndAuditRemoteBackupFile,
} from '../handlers/backup'; } from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive'; import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate'; import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1'; const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
} }
let completed = 0; let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try { try {
await this.touchJob(token); await this.touchJob(token);
const storage = new StorageService(this.env.DB); const storage = new StorageService(this.env.DB);
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
scanStartMs = now.getTime(); scanStartMs = now.getTime();
for (const destination of dueDestinations) { for (const destination of dueDestinations) {
await this.touchJob(token); await this.touchJob(token);
try {
await executeConfiguredBackup( await executeConfiguredBackup(
this.env, this.env,
storage, storage,
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
() => this.touchJob(token) () => this.touchJob(token)
); );
completed += 1; completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
} }
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
ok: true, ok: true,
completed, completed,
failed: failures.length,
failures,
}), { }), {
status: 200, status: 200,
headers: { headers: {
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
replaceExisting, replaceExisting,
!checksumOk, !checksumOk,
body.auditMetadata || null, body.auditMetadata || null,
targetDeviceIdentifier targetDeviceIdentifier,
() => this.touchJob(token)
); );
return new Response(JSON.stringify(result.result), { return new Response(JSON.stringify(result.result), {
@@ -361,7 +372,7 @@ export class BackupTransferRunner {
return badRequest('Remote attachment download payload is invalid'); return badRequest('Remote attachment download payload is invalid');
} }
const blobName = String(body?.blobName || '').trim(); const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) { if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid'); return badRequest('Remote attachment download payload is invalid');
} }
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null); const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
@@ -387,7 +398,7 @@ export class BackupTransferRunner {
const blobNames = Array.from(new Set( const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : []) (Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim()) .map((blobName) => String(blobName || '').trim())
.filter(Boolean) .filter(isSafeBackupAttachmentBlobName)
)); ));
if (!body?.destination || !blobNames.length || blobNames.length > 40) { if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid'); return badRequest('Remote attachment batch download payload is invalid');
@@ -435,7 +446,7 @@ export class BackupTransferRunner {
for (const attachment of body.attachments) { for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim(); const blobName = String(attachment?.blobName || '').trim();
if (!blobName) { if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid'); return badRequest('Attachment chunk payload is invalid');
} }
+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);
} }
+306 -1
View File
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey'; import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
import { import {
accountPasskeyCredentialToResponse, accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus, accountPasskeyPrfStatus,
@@ -29,8 +29,10 @@ import {
verifyAccountPasskeyToken, verifyAccountPasskeyToken,
} from '../utils/account-passkeys'; } from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { createRecoveryCode } from '../utils/recovery-code';
const MAX_ACCOUNT_PASSKEYS = 5; const MAX_ACCOUNT_PASSKEYS = 5;
const MAX_TWO_FACTOR_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> { function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {}; return body && typeof body === 'object' ? body as Record<string, any> : {};
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey); return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
} }
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
return {
Enabled: credentials.length > 0,
enabled: credentials.length > 0,
Keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
Object: 'twoFactorWebAuthn',
object: 'twoFactorWebAuthn',
};
}
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readPrfKeySet(body: Record<string, any>): { function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
if (payload.userId && credential.userId !== payload.userId) { if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user'); throw new Error('Passkey does not belong to this user');
} }
if (credential.purpose !== 'login') {
throw new Error('Passkey is not registered for login');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle); const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId; const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
}); });
} }
export async function buildTwoFactorPasskeyAssertionOptions(
request: Request,
env: Env,
storage: StorageService,
user: User
): Promise<Record<string, unknown> | null> {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (!credentials.length) return null;
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'discouraged',
timeout: 60000,
});
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
return options as unknown as Record<string, unknown>;
}
export async function assertTwoFactorPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
user: User,
deviceResponse: unknown
): Promise<AccountPasskeyCredential> {
const response = normalizeAuthenticationResponse(deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
throw new Error('Passkey is not registered for two-step login');
}
const challenge = readAuthenticationChallenge(response);
if (!challenge) {
throw new Error('Passkey assertion challenge is missing');
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorAuthentication',
user.id,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: false,
});
if (!verification.verified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return credential;
}
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'discouraged',
requireResidentKey: false,
userVerification: 'discouraged',
},
});
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
return jsonResponse(options);
}
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const challenge = readRegistrationChallenge(registrationResponse);
if (!challenge) {
return errorResponse('Passkey challenge is missing', 400);
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorCreate',
userId,
Date.now()
);
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserPresence: true,
requireUserVerification: false,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const transports = normalizeTransports(registrationResponse.response.transports);
await storage.saveAccountPasskeyCredential({
id: generateUUID(),
userId,
purpose: 'twoFactor',
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
supportsPrf: false,
createdAt: now,
updatedAt: now,
});
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = now;
await storage.saveUser(user);
}
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.enable',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: null,
metadata: auditRequestMetadata(request),
});
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const requestedId = Number(body.id ?? body.Id);
if (!Number.isInteger(requestedId) || requestedId <= 0) {
return errorResponse('Invalid key id', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length < 2) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const credential = credentials[requestedId - 1];
if (!credential) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.delete',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> { export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400); if (!body) return errorResponse('Invalid request payload', 400);
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
const credential: AccountPasskeyCredential = { const credential: AccountPasskeyCredential = {
id: generateUUID(), id: generateUUID(),
userId, userId,
purpose: 'login',
name: normalizeAccountPasskeyName(body.name), name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey), publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id, credentialId: verification.registrationInfo.credential.id,
+703 -62
View File
@@ -1,4 +1,4 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { Env, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -6,9 +6,20 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { hashApiKey } from '../utils/api-key';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response';
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // users.master_password_hash is server-side login verification only. It does
@@ -31,6 +42,9 @@ function looksLikeEncString(value: string): boolean {
*/ */
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null { function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0; const type = kdfType ?? 0;
if (type !== 0 && type !== 1) {
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
}
if (type === 0) { if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations // PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) { if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
@@ -64,6 +78,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, '');
} }
@@ -73,10 +158,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -91,34 +175,92 @@ 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 getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
}
async function ensureStoredYubicoCredentials(
storage: StorageService,
env: Env,
email: string,
otp: string
): Promise<YubicoApiCredentials | null> {
const existing = await getStoredYubicoCredentials(storage, env);
if (existing) return existing;
const credentials = await requestYubicoApiCredentials(email, otp);
if (!credentials) return null;
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
return credentials;
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
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',
}; };
} }
@@ -132,8 +274,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
? 'JWT_SECRET is not set' ? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters'; : 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400); return errorResponse(message, 400);
} }
@@ -215,6 +355,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
verifyDevices: true, verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null,
yubikeyKey2: null,
yubikeyKey3: null,
yubikeyKey4: null,
yubikeyKey5: null,
yubikeyNfc: false,
apiKey: null, apiKey: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -244,20 +390,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -294,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
} }
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow( const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint`, `${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute, LIMITS.rateLimit.passwordHintRequestsPerMinute,
60 60
@@ -316,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
); );
} }
const hourlyBudget = await rateLimit.consumeBudgetWithWindow( const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`, `${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour, LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60 60 * 60
@@ -353,7 +510,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
@@ -392,7 +549,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}, },
}); });
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
@@ -406,6 +563,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();
@@ -413,7 +571,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);
} }
@@ -422,7 +581,7 @@ 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, { await writeAuditEvent(storage, {
@@ -441,6 +600,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
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);
@@ -501,7 +673,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
}, },
}); });
return handleGetProfile(request, env, userId); return jsonResponse(keysResponse(user));
} }
// POST/PUT /api/accounts/password // POST/PUT /api/accounts/password
@@ -515,6 +687,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;
@@ -525,6 +698,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();
@@ -537,10 +712,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)) {
@@ -550,17 +731,29 @@ 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);
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHash = await auth.hashPasswordServer(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 (shouldUpdateHint) {
user.masterPasswordHint = nextMasterPasswordHint ?? null;
}
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
@@ -592,8 +785,424 @@ 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',
};
}
function yubiKeyResponse(user: User): Record<string, unknown> {
return {
Enabled: isYubiKeyEnabled(user),
Key1: user.yubikeyKey1,
Key2: user.yubikeyKey2,
Key3: user.yubikeyKey3,
Key4: user.yubikeyKey4,
Key5: user.yubikeyKey5,
Nfc: !!user.yubikeyNfc,
Object: 'twoFactorYubiKey',
};
}
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> {
const enabled = user.verifyDevices !== false;
return {
Enabled: enabled,
enabled,
VerifyDevices: enabled,
verifyDevices: enabled,
Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings',
};
}
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env);
return {
...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId,
YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '',
};
}
// GET /api/two-factor
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 = [];
if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
return jsonResponse({
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));
}
// POST /api/two-factor/get-yubikey
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/get-device-verification-settings
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/device-verification-settings
export async function handlePutDeviceVerificationSettings(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 rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request),
},
});
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// 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);
const matchedCounter = await findMatchingTotpCounter(key, token);
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
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));
}
// PUT/POST /api/two-factor/yubikey
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const keys = [
readBodyString(body, ['key1', 'Key1']),
readBodyString(body, ['key2', 'Key2']),
readBodyString(body, ['key3', 'Key3']),
readBodyString(body, ['key4', 'Key4']),
readBodyString(body, ['key5', 'Key5']),
];
const publicIds: Array<string | null> = [];
let credentials = await getStoredYubicoCredentials(storage, env);
let apiKeyBootstrapOtpIndex: number | null = null;
for (const key of keys) {
const trimmed = key.trim();
if (!trimmed) {
publicIds.push(null);
continue;
}
const publicId = yubiKeyPublicIdFromOtp(trimmed);
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
if (isYubiKeyPublicId(trimmed)) {
publicIds.push(publicId);
continue;
}
if (!credentials) {
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
apiKeyBootstrapOtpIndex = publicIds.length;
}
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
return errorResponse('Invalid YubiKey OTP.', 400);
}
publicIds.push(publicId);
}
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
user.yubikeyKey1 = publicIds[0] ?? null;
user.yubikeyKey2 = publicIds[1] ?? null;
user.yubikeyKey3 = publicIds[2] ?? null;
user.yubikeyKey4 = publicIds[3] ?? null;
user.yubikeyKey5 = publicIds[4] ?? null;
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.yubikey.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// PUT/POST /api/two-factor/yubikey/config
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
if (!clientId) return errorResponse('Yubico Client ID is required.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/yubikey/bootstrap
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
const credentials = await requestYubicoApiCredentials(user.email, otp);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
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 (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
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);
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
user.totpSecret = null;
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
} else {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of credentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
? 'account.totp.disable'
: type === TWO_FACTOR_PROVIDER_YUBIKEY
? 'account.yubikey.disable'
: 'account.webauthn_2fa.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorProviderResponse(type, false));
}
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -601,7 +1210,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 {
@@ -610,14 +1225,26 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
const verified = await verifyTotpToken(normalizedSecret, body.token); let verifiedUser = false;
if (!verified) { if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
} }
user.totpSecret = normalizedSecret; user.totpSecret = normalizedSecret;
@@ -699,7 +1326,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',
}); });
} }
@@ -763,6 +1392,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
} }
user.totpSecret = null; user.totpSecret = null;
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of webAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
@@ -809,23 +1448,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
@@ -862,9 +1504,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) { // Only the fresh secret is returned once; the database stores a hash.
// Upstream apikeys are 30-character random alphanumeric strings const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = await hashApiKey(plainApiKey);
if (rotate) { if (rotate) {
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
@@ -881,10 +1523,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
targetId: user.id, targetId: user.id,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
}
return jsonResponse({ return jsonResponse({
apiKey: user.apiKey, apiKey: plainApiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+28 -10
View File
@@ -69,18 +69,22 @@ export async function handleAdminListUsers(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
return jsonResponse({ const data = await Promise.all(users.map(async user => {
data: users.map(user => ({ const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
status: user.status, status: user.status,
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
creationDate: user.createdAt, creationDate: user.createdAt,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'user', object: 'user',
})), };
}));
return jsonResponse({
data,
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
}); });
@@ -183,6 +187,9 @@ export async function handleAdminClearAuditLogs(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs(); const deleted = await storage.clearAuditLogs();
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
deleted,
}, request);
return jsonResponse({ object: 'auditLogClear', deleted }); return jsonResponse({ object: 'auditLogClear', deleted });
} }
@@ -249,7 +256,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,
@@ -260,12 +267,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', null, null, request); await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -275,12 +284,21 @@ 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,
+77 -32
View File
@@ -1,9 +1,10 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
@@ -31,6 +32,38 @@ 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( async function writeAttachmentAudit(
storage: StorageService, storage: StorageService,
request: Request, request: Request,
@@ -75,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> {
@@ -90,6 +124,10 @@ async function processAttachmentUpload(
} }
const path = getAttachmentObjectKey(cipherId, attachment.id); const path = getAttachmentObjectKey(cipherId, attachment.id);
if (await getBlobObject(env, path)) {
return errorResponse('Attachment file has already been uploaded', 409);
}
try { try {
await putBlobObject(env, path, upload.body, { await putBlobObject(env, path, upload.body, {
size: upload.size, size: upload.size,
@@ -116,6 +154,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 });
@@ -132,7 +171,7 @@ export async function handleCreateAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
@@ -170,16 +209,17 @@ export async function handleCreateAttachment(
await storage.saveAttachment(attachment); await storage.saveAttachment(attachment);
// Add attachment to cipher // Add attachment to cipher
await storage.addAttachmentToCipher(cipherId, attachmentId); await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
@@ -208,18 +248,18 @@ export async function handleUploadAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
export async function handlePublicUploadAttachment( export async function handlePublicUploadAttachment(
@@ -247,17 +287,17 @@ export async function handlePublicUploadAttachment(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, claims.userId);
if (!cipher || cipher.userId !== claims.userId) { if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
// GET /api/ciphers/{cipherId}/attachment/{attachmentId} // GET /api/ciphers/{cipherId}/attachment/{attachmentId}
@@ -272,13 +312,13 @@ export async function handleGetAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -313,12 +353,12 @@ export async function handleUpdateAttachmentMetadata(
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -348,6 +388,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({
@@ -368,10 +409,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const secret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret) return errorResponse('Server configuration error', 500);
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -381,7 +420,7 @@ export async function handlePublicDownloadAttachment(
} }
// Verify token // Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); const claims = await verifyFileDownloadToken(token, secret);
if (!claims) { if (!claims) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
@@ -400,22 +439,23 @@ export async function handlePublicDownloadAttachment(
} }
const path = getAttachmentObjectKey(cipherId, attachmentId); const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
if (!firstUse) { if (!firstUse) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -432,13 +472,13 @@ export async function handleDeleteAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -447,12 +487,13 @@ export async function handleDeleteAttachment(
await deleteBlobObject(env, path); await deleteBlobObject(env, path);
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachmentForUser(attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
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', { await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId, id: attachmentId,
cipherId, cipherId,
@@ -461,11 +502,15 @@ export async function handleDeleteAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
return jsonResponse({ return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments), Cipher: cipherResponse,
cipher: cipherResponse,
Object: 'deleteAttachment',
object: 'deleteAttachment',
}); });
} }
+378
View File
@@ -0,0 +1,378 @@
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
import { StorageService } from '../services/storage';
import { generateUUID } from '../utils/uuid';
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
import { errorResponse, jsonResponse } from '../utils/response';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { LIMITS } from '../config/limits';
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
const AUTH_REQUEST_TYPE_UNLOCK = 1;
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength);
}
function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null {
return (
request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
null
);
}
function getCountryName(request: Request): string | null {
return request.headers.get('CF-IPCountry') || null;
}
function deviceTypeName(type: number): string {
const names: Record<number, string> = {
0: 'Android',
1: 'iOS',
2: 'Chrome Extension',
3: 'Firefox Extension',
4: 'Opera Extension',
5: 'Edge Extension',
6: 'Windows Desktop',
7: 'macOS Desktop',
8: 'Linux Desktop',
9: 'Chrome',
10: 'Firefox',
11: 'Opera',
12: 'Edge',
13: 'Internet Explorer',
14: 'Unknown Browser',
15: 'Android',
16: 'Windows UWP',
17: 'Safari',
18: 'Vivaldi',
19: 'Vivaldi Extension',
20: 'Safari Extension',
21: 'SDK',
22: 'Server',
23: 'Windows CLI',
24: 'macOS CLI',
25: 'Linux CLI',
26: 'DuckDuckGo',
};
return names[type] || `Device ${type}`;
}
function buildOrigin(request: Request): string {
return new URL(request.url).host;
}
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
return {
id: authRequest.id,
Id: authRequest.id,
publicKey: authRequest.publicKey,
PublicKey: authRequest.publicKey,
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
requestDeviceTypeValue: authRequest.requestDeviceType,
RequestDeviceTypeValue: authRequest.requestDeviceType,
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
requestIpAddress: authRequest.requestIpAddress,
RequestIpAddress: authRequest.requestIpAddress,
requestCountryName: authRequest.requestCountryName,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: null,
MasterPasswordHash: null,
creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate,
ResponseDate: authRequest.responseDate,
requestApproved: authRequest.approved ?? false,
RequestApproved: authRequest.approved ?? false,
requestDeviceId: requestDeviceId ?? null,
RequestDeviceId: requestDeviceId ?? null,
origin: buildOrigin(request),
Origin: buildOrigin(request),
object: 'auth-request',
Object: 'auth-request',
};
}
function listResponse<T>(data: T[]) {
return {
data,
Data: data,
object: 'list',
Object: 'list',
continuationToken: null,
ContinuationToken: null,
};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
const body = await request.json();
return body && typeof body === 'object' ? body as Record<string, any> : null;
} catch {
return null;
}
}
async function enforceAuthRequestCreateRateLimit(
request: Request,
env: Env,
email: string,
deviceIdentifier: string
): Promise<Response | null> {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
const rateLimit = new RateLimitService(env.DB);
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
const encodedEmail = encodeURIComponent(email || 'missing');
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
const budgets = await Promise.all([
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
]);
const blocked = budgets.find((budget) => !budget.allowed);
if (!blocked) return null;
return errorResponse('Too many authentication requests. Try again later.', 429);
}
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
for (const name of names) {
if (body[name] !== undefined) return body[name];
}
return undefined;
}
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
}
export async function handleCreateAuthRequest(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase();
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
},
request
);
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
}
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid auth request type.', 400);
}
const user = await storage.getUser(email);
if (!user || user.status !== 'active') {
return errorResponse('User or known device not found.', 400);
}
await storage.pruneExpiredAuthRequests();
const now = new Date().toISOString();
const authRequest: AuthRequestRecord = {
id: generateUUID(),
userId: user.id,
organizationId: null,
type,
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
requestDeviceType: deviceInfo.deviceType,
requestIpAddress: getClientIp(request),
requestCountryName: getCountryName(request),
responseDeviceIdentifier: null,
accessCode,
publicKey,
key: null,
masterPasswordHash: null,
approved: null,
creationDate: now,
responseDate: null,
authenticationDate: null,
};
await storage.createAuthRequest(authRequest);
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleCreateAdminAuthRequest(
request: Request,
env: Env,
userId: string,
userEmail: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
},
request
);
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
}
if (email !== userEmail.toLowerCase()) {
return errorResponse('Email does not match authenticated user.', 400);
}
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Public key, device identifier, and access code are required.', 400);
}
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
const user = await storage.getUserById(userId);
if (!user || user.status !== 'active') {
return errorResponse('User not found.', 404);
}
await storage.pruneExpiredAuthRequests();
const now = new Date().toISOString();
const authRequest: AuthRequestRecord = {
id: generateUUID(),
userId: user.id,
organizationId: null,
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
requestDeviceType: deviceInfo.deviceType,
requestIpAddress: getClientIp(request),
requestCountryName: getCountryName(request),
responseDeviceIdentifier: null,
accessCode,
publicKey,
key: null,
masterPasswordHash: null,
approved: null,
creationDate: now,
responseDate: null,
authenticationDate: null,
};
await storage.createAuthRequest(authRequest);
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const accessCode = normalizeText(url.searchParams.get('code'), 25);
const authRequest = await storage.getAuthRequestById(id);
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequests = await storage.listAuthRequestsByUserId(userId);
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
}
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
await storage.pruneExpiredAuthRequests();
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
const rows = await Promise.all(authRequests.map(async (authRequest) => {
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
}));
return jsonResponse(listResponse(rows));
}
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
return errorResponse('Auth request has already been answered.', 409);
}
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
if (latestForDevice?.id !== authRequest.id) {
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
}
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) ||
'web';
if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400);
}
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, {
approved,
responseDeviceIdentifier,
key,
masterPasswordHash: null,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
if (approved) {
await notifyAuthRequestResponse(env, userId, id);
}
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
}
+231 -54
View File
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { import {
type BackupArchiveBundle, type BackupArchiveBundle,
MAX_BACKUP_ARCHIVE_BYTES,
buildBackupArchive, buildBackupArchive,
inspectBackupArchiveFileNameChecksum, inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName,
parseBackupArchive, parseBackupArchive,
verifyBackupArchiveFileNameChecksum, verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive'; } from '../services/backup-archive';
@@ -18,9 +20,11 @@ import {
loadBackupSettings, loadBackupSettings,
normalizeBackupSettingsInput, normalizeBackupSettingsInput,
normalizeImportedBackupSettings, normalizeImportedBackupSettings,
redactBackupSettingsSecrets,
repairBackupSettings, repairBackupSettings,
requireBackupDestination, requireBackupDestination,
saveBackupSettings, saveBackupSettings,
updateBackupDestinationRuntime,
} from '../services/backup-config'; } from '../services/backup-config';
import { import {
type BackupImportExecutionResult, type BackupImportExecutionResult,
@@ -40,15 +44,60 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
function parseRequestContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog( async function writeAuditLog(
storage: StorageService, storage: StorageService,
actorUserId: string | null, actorUserId: string | null,
@@ -92,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
if (!normalized) { if (!normalized) {
throw new Error('Backup attachment blob is required'); throw new Error('Backup attachment blob is required');
} }
const parts = normalized.split('/').filter(Boolean); if (!isSafeBackupAttachmentBlobName(normalized)) {
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
throw new Error('Backup attachment blob is invalid'); throw new Error('Backup attachment blob is invalid');
} }
return parts.join('/'); return normalized;
}
function contentDispositionBackup(fileName: string | null | undefined): string {
const fallback = 'nodewarden_backup.zip';
const value = String(fileName || fallback)
.replace(/[\\/\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
} }
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json'; const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
@@ -224,6 +280,30 @@ async function uploadRemoteAttachmentChunk(
} }
} }
async function verifyUploadedBackupArchive(
session: RemoteBackupTransferSession,
archive: BackupArchiveBundle
): Promise<'metadata' | 'download'> {
try {
const stat = await session.stat(archive.fileName);
if (stat?.size === archive.bytes.byteLength) {
return 'metadata';
}
} catch {
// Fall through to a full read-back verification when lightweight metadata is unavailable.
}
const remoteFile = await session.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
return 'download';
}
export async function executeConfiguredBackup( export async function executeConfiguredBackup(
env: Env, env: Env,
storage: StorageService, storage: StorageService,
@@ -251,12 +331,14 @@ export async function executeConfiguredBackup(
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date(); const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString();
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastAttemptAt: now.toISOString(),
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
lastErrorAt: null,
lastErrorMessage: null,
}));
try { try {
await touchLease(); await touchLease();
@@ -318,6 +400,7 @@ export async function executeConfiguredBackup(
} }
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease(); await touchLease();
await progress?.({ await progress?.({
@@ -337,14 +420,7 @@ export async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_verify_title', stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail', stageDetail: 'txt_backup_remote_run_progress_verify_detail',
}); });
const remoteFile = await remoteSession.download(archive.fileName); uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
break; break;
} catch (error) { } catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined); await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
@@ -373,14 +449,16 @@ export async function executeConfiguredBackup(
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed'; pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
} }
destination.runtime.lastSuccessAt = new Date().toISOString();
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: archive.fileName,
lastUploadedSizeBytes: archive.bytes.byteLength,
lastUploadedDestination: upload.remotePath,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
@@ -390,6 +468,7 @@ export async function executeConfiguredBackup(
fileName: archive.fileName, fileName: archive.fileName,
fileBytes: archive.bytes.byteLength, fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts, uploadVerificationAttempts: maxArchiveUploadAttempts,
uploadVerificationMethod,
prunedFileCount, prunedFileCount,
pruneError: pruneErrorMessage, pruneError: pruneErrorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
@@ -412,15 +491,18 @@ export async function executeConfiguredBackup(
provider: upload.provider, provider: upload.provider,
}; };
} catch (error) { } catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString(); const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastErrorAt: new Date().toISOString(),
lastErrorMessage: errorMessage,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: errorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
@@ -431,7 +513,7 @@ export async function executeConfiguredBackup(
stageDetail: 'txt_backup_remote_run_progress_failed_detail', stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true, done: true,
ok: false, ok: false,
error: destination.runtime.lastErrorMessage, error: errorMessage,
}); });
throw error; throw error;
} }
@@ -591,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
if (parsed.files[inlinePath]) continue; if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`); const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim(); const blobName = String(ref?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
if (blobName && !seen.has(blobName)) { if (blobName && !seen.has(blobName)) {
seen.add(blobName); seen.add(blobName);
names.push(blobName); names.push(blobName);
@@ -603,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
function toImportStatusCode(message: string): number { function toImportStatusCode(message: string): number {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
if (lower.includes('checksum')) return 400; if (lower.includes('checksum')) return 400;
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400; if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409; if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409; if (lower.includes('not configured') || lower.includes('kv')) return 409;
@@ -619,12 +703,18 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting: boolean, replaceExisting: boolean,
checksumMismatchAccepted: boolean, checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null, auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null targetDeviceIdentifier: string | null = null,
keepAlive?: (() => Promise<void>) | null
): Promise<BackupImportExecutionResult> { ): Promise<BackupImportExecutionResult> {
const touchLease = async () => {
await keepAlive?.();
};
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath; const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
await touchLease();
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes); const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>(); const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => { const progress: BackupRestoreProgressReporter = async (event) => {
await touchLease();
await notifyUserBackupRestoreProgress( await notifyUserBackupRestoreProgress(
env, env,
actorUserId, actorUserId,
@@ -642,6 +732,7 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting, replaceExisting,
{ {
loadAttachment: async (blobName) => { loadAttachment: async (blobName) => {
await touchLease();
const normalized = String(blobName || '').trim(); const normalized = String(blobName || '').trim();
if (!normalized) return null; if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) { if (externalAttachmentCache.has(normalized)) {
@@ -664,6 +755,7 @@ export async function importAndAuditRemoteBackupFile(
} catch { } catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null)); externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
} }
await touchLease();
return externalAttachmentCache.get(normalized) || null; return externalAttachmentCache.get(normalized) || null;
}, },
}, },
@@ -778,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse(settings); return jsonResponse(redactBackupSettingsSecrets(settings));
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409); return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
} }
@@ -787,13 +879,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch { } catch {
return errorResponse('Backup settings payload is invalid', 400); return errorResponse('Backup settings payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -814,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request); }, request);
return jsonResponse(next); return jsonResponse(redactBackupSettingsSecrets(next));
} }
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -837,13 +932,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch { } catch {
return errorResponse('Backup settings repair payload is invalid', 400); return errorResponse('Backup settings repair payload is invalid', 400);
} }
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -864,22 +962,25 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request); }, request);
return jsonResponse(next); return jsonResponse(redactBackupSettingsSecrets(next));
} }
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>(); body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup run payload is invalid', 400); return errorResponse('Backup run payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, { const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id, actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request), auditMetadata: auditRequestMetadata(request),
@@ -898,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
provider: outcome.result.provider, provider: outcome.result.provider,
remotePath: outcome.result.remotePath, remotePath: outcome.result.remotePath,
}, },
settings: outcome.settings, settings: redactBackupSettingsSecrets(outcome.settings),
}); });
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
@@ -928,19 +1029,29 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, { return new Response(remoteFile.bytes, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': remoteFile.contentType || 'application/zip', 'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`, 'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} catch (error) { } catch (error) {
@@ -951,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup integrity payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path); const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({ return jsonResponse({
@@ -974,12 +1094,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup delete payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
await deleteRemoteBackupFile(destination, path); await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, { await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
@@ -994,13 +1123,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try { try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch { } catch {
return errorResponse('Remote restore payload is invalid', 400); return errorResponse('Remote restore payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try { try {
const path = ensureRemoteRestoreCandidate(String(body.path || '')); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1166,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null; let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>(); body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup export payload is invalid', 400); return errorResponse('Backup export payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle; let archive: BackupArchiveBundle;
try { try {
const progress = async (event: { const progress = async (event: {
@@ -1096,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/zip', 'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`, 'Content-Disposition': contentDispositionBackup(archive.fileName),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -1107,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
try { try {
const url = new URL(request.url); const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || ''); let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
if (request.method === 'POST') {
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName); const object = await getBlobObject(env, blobName);
if (!object) { if (!object) {
return errorResponse('Backup attachment blob not found', 404); return errorResponse('Backup attachment blob not found', 404);
@@ -1128,6 +1290,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const contentType = request.headers.get('Content-Type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
}
const declaredSize = parseRequestContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
let formData: FormData; let formData: FormData;
try { try {
formData = await request.formData(); formData = await request.formData();
@@ -1139,6 +1310,12 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) { if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
+293 -59
View File
@@ -7,11 +7,20 @@ import {
CipherResponse, CipherResponse,
CipherSecureNote, CipherSecureNote,
CipherSshKey, CipherSshKey,
CipherBankAccount,
CipherDriversLicense,
CipherPassport,
Attachment, Attachment,
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';
@@ -26,6 +35,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients. // attachments, import/export, and current official clients.
export interface CipherResponseOptions { export interface CipherResponseOptions {
preserveRepairableUris?: boolean; preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
} }
export function shouldPreserveRepairableCipherUris(request: Request): boolean { export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -42,6 +52,28 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
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,
@@ -51,6 +83,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) {
@@ -141,6 +227,12 @@ 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 { function shouldAcceptCipherKey(value: unknown): boolean {
return value == null || value === '' || isValidEncString(value); return value == null || value === '' || isValidEncString(value);
} }
@@ -151,17 +243,63 @@ function normalizeCipherKeyForStorage(value: unknown): string | null {
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;
} }
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
'bankName',
'nameOnAccount',
'accountType',
'accountNumber',
'routingNumber',
'branchNumber',
'pin',
'swiftCode',
'iban',
'bankContactPhone',
] as const;
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
'firstName',
'middleName',
'lastName',
'dateOfBirth',
'licenseNumber',
'issuingCountry',
'issuingState',
'issueDate',
'expirationDate',
'issuingAuthority',
'licenseClass',
] as const;
const PASSPORT_ENCRYPTED_KEYS = [
'surname',
'givenName',
'dateOfBirth',
'sex',
'birthPlace',
'nationality',
'issuingCountry',
'passportNumber',
'passportType',
'nationalIdentificationNumber',
'issuingAuthority',
'issueDate',
'expirationDate',
] as const;
function normalizeCipherForStorage(cipher: Cipher): Cipher { function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -188,7 +326,12 @@ export function normalizeCipherLoginForCompatibility(
): any { ): 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 = normalizeCipherLoginUrisForCompatibility(next.uris, { next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
requiresUriChecksum, requiresUriChecksum,
@@ -214,23 +357,19 @@ function normalizeCipherLoginUrisForCompatibility(
const hasChecksum = isValidEncString(next.uriChecksum); const hasChecksum = isValidEncString(next.uriChecksum);
const hasMatch = next.match != null; const hasMatch = next.match != null;
if (hasUri && hasChecksum) { if (hasUri && String(next.uri).trim().length > 10000) continue;
if (hasChecksum && String(next.uriChecksum).trim().length > 10000) {
next.uriChecksum = null;
}
if (hasUri && isValidEncString(next.uriChecksum)) {
out.push(next); out.push(next);
continue; continue;
} }
if (hasUri && !hasChecksum) { if (hasUri && !hasChecksum) {
if (options.preserveRepairableUris) { // Official Bitwarden treats UriChecksum as nullable encrypted metadata.
// Preserve the encrypted URI so NodeWarden Web can decrypt it and repair // Keep the URI intact and let clients that can repair checksums do so.
// the missing checksum. Dropping it here makes the URI appear lost and
// can turn a display-only compatibility issue into data loss on save.
out.push({ ...next, uriChecksum: null });
continue;
}
// Bitwarden browser clients using the SDK drop item-key encrypted URIs
// whose checksum is missing/invalid. User-key encrypted legacy/import
// entries bypass this validation and can safely keep the URI.
if (options.requiresUriChecksum) continue;
out.push({ ...next, uriChecksum: null }); out.push({ ...next, uriChecksum: null });
continue; continue;
} }
@@ -243,14 +382,69 @@ function normalizeCipherLoginUrisForCompatibility(
return out.length ? out : null; return out.length ? out : null;
} }
function hasMissingLoginUriChecksum(cipher: Cipher): boolean { export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null {
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false; if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.';
const uris = (cipher.login as any).uris; if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.';
if (!Array.isArray(uris)) return false;
return uris.some((uri: any) => { const login = cipher.login as any;
if (!uri || typeof uri !== 'object') return false; if (login && typeof login === 'object') {
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum); if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.';
}); if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.';
if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.';
if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (Array.isArray(login.uris)) {
for (const uri of login.uris) {
if (!uri || typeof uri !== 'object') continue;
if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
}
}
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
if (Array.isArray(login.fido2Credentials)) {
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
for (const cred of login.fido2Credentials) {
if (!cred || typeof cred !== 'object') continue;
for (const key of fido2EncryptedKeys) {
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
}
}
}
}
// Validate SSH key fields — all three must be encrypted strings.
const sshKey = cipher.sshKey as any;
if (sshKey && typeof sshKey === 'object') {
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
];
for (const [label, source, keys] of typedEncryptedObjects) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
return `${label} ${key} must be an encrypted string.`;
}
}
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
if (!entry || typeof entry !== 'object') continue;
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
}
}
return null;
} }
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
@@ -589,7 +783,14 @@ export function cipherToResponse(
!!responseCipherKey, !!responseCipherKey,
!!options.preserveRepairableUris !!options.preserveRepairableUris
); );
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, {
cardholderName: 1000,
brand: 1000,
number: 1000,
expMonth: 1000,
expYear: 1000,
code: 1000,
});
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title', 'title',
'firstName', 'firstName',
@@ -611,29 +812,40 @@ export function cipherToResponse(
'licenseNumber', 'licenseNumber',
]); ]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2 const normalizedBankAccount = sanitizeEncryptedObject(
(passthrough as any).bankAccount ?? null,
BANK_ACCOUNT_ENCRYPTED_KEYS
);
const normalizedDriversLicense = sanitizeEncryptedObject(
(passthrough as any).driversLicense ?? null,
DRIVERS_LICENSE_ENCRYPTED_KEYS
);
const normalizedPassport = sanitizeEncryptedObject(
(passthrough as any).passport ?? null,
PASSPORT_ENCRYPTED_KEYS
);
const responseType = Number(cipher.type) || 1;
const normalizedSecureNote = responseType === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 } ? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null; : null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments); const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
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: responseType,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt, creationDate: createdAt,
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(responseAttachments), attachments: formatAttachments(responseAttachments),
@@ -646,7 +858,11 @@ export function cipherToResponse(
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields), fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
bankAccount: responseType === 6 ? normalizedBankAccount : null,
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
passport: responseType === 8 ? normalizedPassport : null,
key: responseCipherKey, key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -680,9 +896,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds( const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id) filteredCiphers.map((cipher) => cipher.id)
); );
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap. // Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
@@ -699,7 +916,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// GET /api/ciphers/:id // GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -714,8 +931,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> { async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true; if (!folderId) return true;
const folder = await storage.getFolder(folderId); const folder = await storage.getFolderForUser(folderId, userId);
return !!(folder && folder.userId === userId); return !!folder;
} }
// POST /api/ciphers // POST /api/ciphers
@@ -739,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']); const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']); const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) { if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
@@ -768,10 +988,15 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null); cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null); cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null); cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
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) {
@@ -779,13 +1004,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404); if (!folderOk) return errorResponse('Folder not found', 404);
} }
if (hasMissingLoginUriChecksum(cipher)) {
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
}
await storage.saveCipher(cipher); 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); const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
@@ -797,7 +1019,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id // PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id); const existingCipher = await storage.getCipherForUser(id, userId);
if (!existingCipher || existingCipher.userId !== userId) { if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -820,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']); const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']); const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData); const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData); const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
@@ -835,10 +1060,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
} }
if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) {
return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400);
}
const nextType = Number(cipherData.type) || existingCipher.type; const nextType = Number(cipherData.type) || existingCipher.type;
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
@@ -872,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null; cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null; cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null; cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
if (incomingPasswordHistory.present) { if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null; cipher.passwordHistory = incomingPasswordHistory.value ?? null;
} }
@@ -887,6 +1111,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) {
@@ -894,14 +1120,11 @@ 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);
} }
if (hasMissingLoginUriChecksum(cipher)) {
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
}
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData); await 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); const responseOptions = cipherResponseOptionsForRequest(request);
@@ -913,7 +1136,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id // DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -926,6 +1149,7 @@ 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', { await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id, id: cipher.id,
type: cipher.type, type: cipher.type,
@@ -944,7 +1168,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// - If item is already soft-deleted -> hard delete. // - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -955,6 +1179,7 @@ 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', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id, id,
type: cipher.type, type: cipher.type,
@@ -970,7 +1195,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -982,6 +1207,7 @@ 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', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id, id,
type: cipher.type, type: cipher.type,
@@ -994,7 +1220,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore // PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1006,6 +1232,7 @@ 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, [], cipherResponseOptionsForRequest(request)) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1015,7 +1242,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// PUT /api/ciphers/:id/partial - Update only favorite/folderId // PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1045,6 +1272,7 @@ 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, [], cipherResponseOptionsForRequest(request)) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1106,7 +1334,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
// PUT/POST /api/ciphers/:id/archive // PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1121,6 +1349,7 @@ 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(
@@ -1131,7 +1360,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
// PUT/POST /api/ciphers/:id/unarchive // PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1169,6 +1398,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);
@@ -1193,6 +1423,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);
@@ -1216,6 +1447,7 @@ 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', { await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length, count: body.ids.length,
}); });
@@ -1242,6 +1474,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 });
@@ -1278,6 +1511,7 @@ 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', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length, count: ownedIds.length,
requestedCount: ids.length, requestedCount: ids.length,
+122 -10
View File
@@ -3,9 +3,10 @@ import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59); const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
@@ -47,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt, creationDate: device.createdAt,
RevisionDate: device.updatedAt, RevisionDate: device.updatedAt,
revisionDate: device.updatedAt, revisionDate: device.updatedAt,
LastActivityDate: device.lastSeenAt,
lastActivityDate: device.lastSeenAt,
LastSeenAt: device.lastSeenAt, LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt, lastSeenAt: device.lastSeenAt,
HasStoredDevice: true, HasStoredDevice: true,
@@ -122,6 +125,85 @@ function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128); return String(value || '').trim().slice(0, 128);
} }
function parseDeviceType(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
// POST /api/devices
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
const storage = new StorageService(env.DB);
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
if (pushToken) {
const device = await storage.getDevice(userId, identifier);
const pushUuid = device?.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: identifier,
type,
pushUuid,
pushToken,
});
}
}
const device = await storage.getDevice(userId, identifier);
if (!device) return errorResponse('Device registration failed', 500);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.register',
category: 'device',
level: 'info',
targetType: 'device',
targetId: identifier,
metadata: auditRequestMetadata(request),
});
return jsonResponse(buildDeviceResponse(device));
}
// POST /api/devices/lost-trust
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request) || {};
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
},
request
);
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
const storage = new StorageService(env.DB);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.lost_trust',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: deviceInfo.deviceIdentifier,
metadata: {
deviceIdentifier: deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
@@ -223,6 +305,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,
@@ -325,10 +409,12 @@ 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); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -537,10 +623,12 @@ 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); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -557,18 +645,36 @@ export async function handleDeactivateDevice(
} }
// 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 });
} }
@@ -594,9 +700,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 });
} }
+80
View File
@@ -0,0 +1,80 @@
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
const EMPTY_FORMS_BODY = JSON.stringify({
schemaVersion: '1.0.0',
hosts: {},
});
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Bitwarden Fill Assist Forms v1',
type: 'object',
required: ['schemaVersion', 'hosts'],
properties: {
schemaVersion: { type: 'string' },
hosts: { type: 'object' },
},
additionalProperties: true,
});
const EMPTY_MANIFEST_BODY = JSON.stringify({
buildId: 'nodewarden-empty-fill-assist-v1',
timestamp: '2026-07-06T00:00:00.000Z',
gitSha: 'nodewarden',
maps: {
forms: {
v1: {
filename: EMPTY_FORMS_FILENAME,
cid: EMPTY_FORMS_CID,
schema: EMPTY_FORMS_SCHEMA_FILENAME,
deprecated: false,
},
},
},
});
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
linked: false,
maxAge: '86400s',
debugString: 'No matching digital asset link policy is configured for this server.',
});
function fillAssistJsonResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
}
function normalizeFilename(filename: string): string {
const raw = String(filename || '').trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function handleFillAssistManifest(): Response {
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
}
export function handleFillAssistForms(filename: string): Response {
const normalized = normalizeFilename(filename);
if (normalized === EMPTY_FORMS_FILENAME) {
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
}
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
}
return new Response('Not found', { status: 404 });
}
export function handleDigitalAssetLinkCheck(): Response {
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
}
+41 -4
View File
@@ -1,5 +1,10 @@
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';
@@ -75,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id // GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -111,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);
} }
@@ -118,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id // PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -139,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));
} }
@@ -146,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id // DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -156,6 +173,12 @@ 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', { await writeFolderAudit(storage, request, userId, 'folder.delete', {
id, id,
}); });
@@ -179,9 +202,23 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
return errorResponse('Folder ids are required', 400); return errorResponse('Folder ids are required', 400);
} }
const folders = (
await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolderForUser(id, userId);
return folder;
}))
).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) { if (revisionDate) {
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', { await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length, count: ids.length,
}); });
+250 -56
View File
@@ -1,15 +1,16 @@
import { Env, TokenResponse } from '../types'; import { Env, TokenResponse, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { 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,
@@ -17,17 +18,28 @@ import {
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { import {
assertAccountPasskeyCredential, assertAccountPasskeyCredential,
assertTwoFactorPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
buildTwoFactorPasskeyAssertionOptions,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const 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. const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
// Keep request parsing backward-compatible with historical provider values (8 / 100). const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_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 {
@@ -48,6 +60,44 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; 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';
} }
@@ -64,16 +114,31 @@ function parseCookieValue(request: Request, name: string): string | null {
return null; return null;
} }
function constantTimeEquals(a: string, b: string): boolean { function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
const encA = new TextEncoder().encode(a); for (const name of names) {
const encB = new TextEncoder().encode(b); const value = body[name];
if (encA.length !== encB.length) return false; if (value != null) return value;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
} }
return diff === 0; return undefined;
}
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function loginRateLimitKey(clientIdentifier: string, grantType: string, subject: string): Promise<string> {
const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`);
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
}
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
} }
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
@@ -108,6 +173,30 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -130,19 +219,51 @@ 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',
};
}
async function twoFactorRequiredResponse(
request: Request,
env: Env,
storage: StorageService,
user?: User,
message: string = 'Two factor required.'
): Promise<Response> {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
// parse the challenge if an unknown recovery provider key such as "8" is included.
const providers: string[] = [];
let webAuthnOptions: Record<string, unknown> | null = null;
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
if (user) {
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
}
const providers2: Record<string, Record<string, unknown> | null> = {};
for (const provider of providers) {
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
? { Nfc: user?.yubikeyNfc ?? false }
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
? webAuthnOptions
: null;
}
const customResponse = { 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.
@@ -228,16 +349,17 @@ 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; const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -272,11 +394,34 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
authRequest.type === 0 &&
authRequest.approved === true &&
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
}
} else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
}
if (!valid) { if (!valid) {
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
action: 'auth.login.failed.bad_password', action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth', category: 'auth',
level: 'warn', level: 'warn',
targetType: 'user', targetType: 'user',
@@ -294,11 +439,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
// Optional 2FA: enabled only by per-user secret. // Optional 2FA: enabled by any supported per-user provider.
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
const canUseRecoveryCode = !!user.totpRecoveryCode; const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -308,7 +454,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // 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 await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -323,26 +469,68 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode); return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); if (!effectiveTotpSecret) {
if (!totpOk) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) {
const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken);
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const credentials = await getStoredYubicoCredentials(storage, env);
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
if (!effectiveWebAuthnCredentials.length) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
let deviceResponse: unknown;
try {
deviceResponse = JSON.parse(normalizedTwoFactorToken);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
try {
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
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)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
user.totpSecret = null; user.totpSecret = null;
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
for (const credential of effectiveWebAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
rememberRequested = false; rememberRequested = false;
} else { } else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt. // Unsupported provider for this server profile behaves as an invalid 2FA attempt.
@@ -371,10 +559,14 @@ 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);
@@ -402,7 +594,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
accountKeys: accountKeys, accountKeys: accountKeys,
@@ -412,9 +604,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,
@@ -428,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier; const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -438,7 +629,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse; let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') { if (typeof deviceResponse === 'string') {
try { try {
@@ -492,12 +682,14 @@ 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);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -532,12 +724,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,
}; };
@@ -554,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope; const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) { if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -570,7 +763,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const uid = clientId.slice(5);
const user = await storage.getUserById(uid); const user = await storage.getUserById(uid);
if (!user) { if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
@@ -594,7 +786,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
@@ -622,6 +814,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
@@ -662,9 +855,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,
@@ -714,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
`${clientIdentifier}:send-password` clientIdentifier
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
@@ -802,9 +993,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,
@@ -855,6 +1044,11 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
+32 -8
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 {
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
favorite?: boolean; favorite?: boolean;
reprompt?: number; reprompt?: number;
sshKey?: any | null; sshKey?: any | null;
bankAccount?: any | null;
driversLicense?: any | null;
passport?: any | null;
key?: string | null; key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
return undefined; return undefined;
} }
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> { async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) { for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize); const chunk = statements.slice(i, i + chunkSize);
@@ -112,9 +121,9 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
const folders = importData.folders || []; const folders = Array.isArray(importData.folders) ? importData.folders : [];
const ciphers = importData.ciphers || []; const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
const folderRelationships = importData.folderRelationships || []; const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) { if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400); return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
@@ -128,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const folderRows: Folder[] = []; const folderRows: Folder[] = [];
for (let i = 0; i < folders.length; i++) { for (let i = 0; i < folders.length; i++) {
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
const folderId = generateUUID(); const folderId = generateUUID();
folderIdMap.set(i, folderId); folderIdMap.set(i, folderId);
const folder: Folder = { const folder: Folder = {
id: folderId, id: folderId,
userId: userId, userId: userId,
name: folders[i].name, name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -157,24 +167,31 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Build cipher index -> folder id mapping from relationships // Build cipher index -> folder id mapping from relationships
const cipherFolderMap = new Map<number, string>(); const cipherFolderMap = new Map<number, string>();
for (const rel of folderRelationships) { for (const rel of folderRelationships) {
if (!rel || typeof rel !== 'object') continue;
const folderId = folderIdMap.get(rel.value); const folderId = folderIdMap.get(rel.value);
if (folderId) { if (folderId) {
cipherFolderMap.set(rel.key, folderId); cipherFolderMap.set(rel.key, folderId);
} }
} }
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Create ciphers // Create ciphers
const cipherRows: Cipher[] = []; const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null; const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : null);
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']); const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']); const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']); const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']); const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']); const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']); const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']); const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
passwordHistory: passwordHistory ?? null, passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
bankAccount: bankAccount ?? null,
driversLicense: driversLicense ?? null,
passport: passport ?? null,
key: key ?? null, key: key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -252,6 +272,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 });
+23
View File
@@ -1,4 +1,6 @@
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import type { Env, JWTPayload } from '../types'; import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
@@ -56,3 +58,24 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
} }
return stub.fetch(new Request(forwardedUrl.toString(), request)); return stub.fetch(new Request(forwardedUrl.toString(), request));
} }
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
if (!authRequestId) return errorResponse('Token is required', 400);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(authRequestId);
if (!authRequest || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
+50 -13
View File
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
import { import {
getBlobStorageMaxBytes, getBlobStorageMaxBytes,
getSendFileObjectKey, getSendFileObjectKey,
getBlobObject,
putBlobObject, putBlobObject,
deleteBlobObject, deleteBlobObject,
} from '../services/blob-store'; } from '../services/blob-store';
@@ -16,6 +17,9 @@ import {
formatSize, formatSize,
getAliasedProp, getAliasedProp,
normalizeEmails, normalizeEmails,
notifySendCreateForRequest,
notifySendDeleteForRequest,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseDate, parseDate,
parseFileLength, parseFileLength,
@@ -31,6 +35,8 @@ import {
} from './sends-shared'; } from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
async function writeSendAudit( async function writeSendAudit(
storage: StorageService, storage: StorageService,
request: Request, request: Request,
@@ -79,8 +85,13 @@ async function processSendFileUpload(
return upload; return upload;
} }
const path = getSendFileObjectKey(send.id, fileId);
if (await getBlobObject(env, path)) {
return errorResponse('Send file has already been uploaded', 409);
}
try { try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, { await putBlobObject(env, path, upload.body, {
size: upload.size, size: upload.size,
contentType: upload.contentType, contentType: upload.contentType,
customMetadata: { customMetadata: {
@@ -99,6 +110,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 });
} }
@@ -130,7 +142,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
@@ -206,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
if (authTypeRaw.present && requestedAuthType === null) { if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value); const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const send: Send = { const send: Send = {
@@ -249,6 +267,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));
} }
@@ -329,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
if (authTypeRaw.present && requestedAuthType === null) { if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value); const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const send: Send = { const send: Send = {
@@ -372,6 +397,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);
@@ -395,7 +421,7 @@ export async function handleGetSendFileUpload(
): Promise<Response> { ): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -430,7 +456,7 @@ export async function handleUploadSendFile(
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -466,7 +492,7 @@ export async function handlePublicUploadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, claims.userId);
if (!send || send.userId !== claims.userId) { if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -479,7 +505,7 @@ export async function handlePublicUploadSendFile(
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -586,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (parsedAuthType === null) { if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
send.authType = parsedAuthType; if (parsedAuthType === SendAuthType.Email) {
if (parsedAuthType !== SendAuthType.Email) { return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
send.emails = null;
} }
send.authType = parsedAuthType;
send.emails = null;
} }
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
@@ -598,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.emails = normalizedEmails; send.emails = normalizedEmails;
if (send.emails) { if (send.emails) {
send.authType = SendAuthType.Email; send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) { } else if (Number(send.authType) === SendAuthType.Email) {
send.authType = SendAuthType.None; send.authType = SendAuthType.None;
} }
} }
@@ -619,13 +649,14 @@ 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> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -641,6 +672,7 @@ 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', { await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId, id: sendId,
type: send.type, type: send.type,
@@ -676,6 +708,9 @@ 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', { await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length, count: sends.length,
requestedCount: body.ids.length, requestedCount: body.ids.length,
@@ -687,7 +722,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -697,6 +732,7 @@ 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', { await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id, id: send.id,
type: send.type, type: send.type,
@@ -707,7 +743,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -718,6 +754,7 @@ 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', { await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id, id: send.id,
type: send.type, type: send.type,
+41 -13
View File
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createSendAccessToken, createSendAccessToken,
createSendFileDownloadToken, createSendFileDownloadToken,
@@ -21,6 +21,7 @@ import {
getSafeJwtSecret, getSafeJwtSecret,
hasEmailAuth, hasEmailAuth,
isSendAvailable, isSendAvailable,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseStoredSendData, parseStoredSendData,
resolveSendFromIdOrAccessId, resolveSendFromIdOrAccessId,
@@ -33,6 +34,14 @@ import {
verifySendPasswordHashB64, verifySendPasswordHashB64,
} from './sends-shared'; } from './sends-shared';
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'send-file';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> { export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId); const sendId = fromAccessId(accessId);
@@ -59,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB); sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) { if (!sendPasswordCheck.allowed) {
@@ -90,6 +99,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);
@@ -102,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string, idOrAccessId: string,
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const safeSecret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { if (!safeSecret.ok) return safeSecret.response;
return errorResponse('Server configuration error', 500); const { secret } = safeSecret;
}
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
@@ -133,7 +142,7 @@ export async function handleAccessSendFile(
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB); sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) { if (!sendPasswordCheck.allowed) {
@@ -163,6 +172,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 +213,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 +253,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);
@@ -278,9 +290,14 @@ export async function handleDownloadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); const send = await storage.getSend(sendId);
if (!object) { if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
return errorResponse('Send file not found', 404); return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const data = parseStoredSendData(send);
const expectedFileId = typeof data.id === 'string' ? data.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
} }
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
@@ -288,11 +305,19 @@ export async function handleDownloadSendFile(
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -303,7 +328,7 @@ export async function issueSendAccessToken(
passwordHashB64?: string | null, passwordHashB64?: string | null,
password?: string | null, password?: string | null,
rateLimit?: RateLimitService, rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string clientIdentifier?: string
): Promise<{ token: string } | { error: Response }> { ): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env); const jwt = getSafeJwtSecret(env);
if (!jwt.ok) { if (!jwt.ok) {
@@ -343,11 +368,14 @@ export async function issueSendAccessToken(
Object: 'error', Object: 'error',
}, },
}, },
400 501
), ),
}; };
} }
const sendPasswordLimitIpKey =
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
if (send.passwordHash) { if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) { if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
+69 -7
View File
@@ -1,5 +1,10 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserSendCreate,
notifyUserSendDelete,
notifyUserSendUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
@@ -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) {
@@ -105,7 +155,15 @@ export function formatSize(bytes: number): string {
export function parseDate(raw: unknown): Date | null { export function parseDate(raw: unknown): Date | null {
if (typeof raw !== 'string' || !raw.trim()) return null; if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw); let value = raw.trim();
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
value += 'Z';
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
value = value.replace(' ', 'T') + 'Z';
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
return date; return date;
} }
@@ -321,7 +379,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return { ok: false, response: errorResponse('Server configuration error', 500) }; return { ok: false, response: errorResponse('Server configuration error', 500) };
} }
return { ok: true, secret }; return { ok: true, secret };
@@ -384,8 +442,8 @@ export type PublicSendAccessValidationResult =
| { ok: true } | { ok: true }
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' }; | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
export function sendPasswordLimitKey(clientIdentifier: string): string { export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`; return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
} }
function sendPasswordLockMessage(retryAfterSeconds: number): string { function sendPasswordLockMessage(retryAfterSeconds: number): string {
@@ -414,7 +472,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> { export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
if (hasEmailAuth(send)) { if (hasEmailAuth(send)) {
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' }; return {
ok: false,
response: errorResponse('Send email verification is not supported by this server.', 501),
reason: 'email_auth_unsupported',
};
} }
if (!send.passwordHash) return { ok: true }; if (!send.passwordHash) return { ok: true };
+6 -27
View File
@@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira
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 { 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.
@@ -84,40 +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 webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = { 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) || [], { preserveRepairableUris }); const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
@@ -149,6 +126,7 @@ 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,
@@ -156,6 +134,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
KeyConnectorOption: null, KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null, WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions, WebAuthnPrfOptions: webAuthnPrfOptions,
V2UpgradeToken: null,
Object: 'userDecryption', Object: 'userDecryption',
}, },
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
+3 -3
View File
@@ -89,7 +89,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request); const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env); const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) { if (assetResponse) {
return applyCors(normalizedRequest, assetResponse); return applyCors(normalizedRequest, assetResponse, env);
} }
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
@@ -107,11 +107,11 @@ export default {
}, },
500 500
); );
return applyCors(normalizedRequest, resp); return applyCors(normalizedRequest, resp, env);
} }
const resp = await handleRequest(normalizedRequest, env); const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp); return applyCors(normalizedRequest, resp, env);
}, },
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+3 -3
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser); return handleAdminExportBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/blob' && method === 'GET') { if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
return handleDownloadAdminBackupAttachment(request, env, actorUser); return handleDownloadAdminBackupAttachment(request, env, actorUser);
} }
@@ -50,11 +50,11 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') { if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
return handleInspectAdminRemoteBackup(request, env, actorUser); return handleInspectAdminRemoteBackup(request, env, actorUser);
} }
+26 -2
View File
@@ -4,7 +4,7 @@ import {
handleAdminCreateInvite, handleAdminCreateInvite,
handleAdminListInvites, handleAdminListInvites,
handleAdminDeleteAllInvites, handleAdminDeleteAllInvites,
handleAdminRevokeInvite, handleAdminDeleteInvite,
handleAdminSetUserStatus, handleAdminSetUserStatus,
handleAdminDeleteUser, handleAdminDeleteUser,
handleAdminListAuditLogs, handleAdminListAuditLogs,
@@ -13,6 +13,23 @@ import {
handleAdminClearAuditLogs, handleAdminClearAuditLogs,
} from './handlers/admin'; } from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup'; import { handleAdminBackupRoute } from './router-admin-backup';
import { errorResponse } from './utils/response';
function isKnownAdminPath(path: string): boolean {
return (
path === '/api/admin/users' ||
path === '/api/admin/logs' ||
path === '/api/admin/logs/settings' ||
path === '/api/admin/invites' ||
path.startsWith('/api/admin/backup') ||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
);
}
function isActiveAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
export async function handleAdminRoute( export async function handleAdminRoute(
request: Request, request: Request,
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (!isKnownAdminPath(path)) {
return null;
}
if (!isActiveAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (path === '/api/admin/users' && method === 'GET') { if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser); return handleAdminListUsers(request, env, actorUser);
} }
@@ -52,7 +76,7 @@ export async function handleAdminRoute(
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i); const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') { if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]); const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode); return handleAdminDeleteInvite(request, env, actorUser, inviteCode);
} }
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i); const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
+141 -5
View File
@@ -1,8 +1,9 @@
import type { Env, User } from './types'; import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response'; import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile, handleUpdateProfile,
handleGetKeys,
handleSetKeys, handleSetKeys,
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
@@ -11,6 +12,16 @@ import {
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleGetTwoFactorYubiKey,
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleGetDeviceVerificationSettings,
handlePutDeviceVerificationSettings,
handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
} from './handlers/accounts'; } from './handlers/accounts';
@@ -69,11 +80,22 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import { import {
handleCreateAccountPasskeyCredential, handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential, handleDeleteAccountPasskeyCredential,
handleDeleteTwoFactorWebAuthn,
handleGetAccountPasskeyAttestationOptions, handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials, handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions, handleGetAccountPasskeyUpdateAssertionOptions,
handleGetTwoFactorWebAuthn,
handleGetTwoFactorWebAuthnChallenge,
handlePutTwoFactorWebAuthn,
handleUpdateAccountPasskeyEncryption, handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys'; } from './handlers/account-passkeys';
import {
handleCreateAdminAuthRequest,
handleGetAuthRequest,
handleListAuthRequests,
handleListPendingAuthRequests,
handleUpdateAuthRequest,
} from './handlers/auth-requests';
export async function handleAuthenticatedRoute( export async function handleAuthenticatedRoute(
request: Request, request: Request,
@@ -95,6 +117,40 @@ export async function handleAuthenticatedRoute(
} }
} }
if ((path === '/api/accounts/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('KDF changes are not supported by this server.');
}
const mailBackedAccountPaths = new Set([
'/api/accounts/email-token',
'/accounts/email-token',
'/api/accounts/verify-email',
'/accounts/verify-email',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/accounts/request-otp',
'/accounts/request-otp',
'/api/accounts/verify-otp',
'/accounts/verify-otp',
]);
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('Email delivery is not supported by this server.');
}
const emailTwoFactorPaths = new Set([
'/api/two-factor/get-email',
'/two-factor/get-email',
'/api/two-factor/send-email',
'/two-factor/send-email',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
'/api/two-factor/email',
'/two-factor/email',
]);
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
return unsupportedResponse('Email two-step login is not supported by this server.');
}
if (path === '/api/accounts/profile') { if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId); if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId); if (method === 'PUT') return handleUpdateProfile(request, env, userId);
@@ -105,8 +161,10 @@ export async function handleAuthenticatedRoute(
return handleChangePassword(request, env, userId); return handleChangePassword(request, env, userId);
} }
if (path === '/api/accounts/keys' && method === 'POST') { if (path === '/api/accounts/keys') {
return handleSetKeys(request, env, userId); if (method === 'GET') return handleGetKeys(request, env, userId);
if (method === 'POST') return handleSetKeys(request, env, userId);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/accounts/totp') { if (path === '/api/accounts/totp') {
@@ -119,6 +177,66 @@ export async function handleAuthenticatedRoute(
return handleGetTotpRecoveryCode(request, env, userId); return handleGetTotpRecoveryCode(request, env, userId);
} }
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
return handleGetTwoFactorYubiKey(request, env, userId);
}
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
return handleGetDeviceVerificationSettings(request, env, userId);
}
if (path === '/api/two-factor/device-verification-settings') {
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
}
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
}
if (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/webauthn') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
}
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
}
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId);
}
if (path === '/api/accounts/revision-date' && method === 'GET') { if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId); return handleGetRevisionDate(request, env, userId);
} }
@@ -262,8 +380,26 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
} }
if (path.startsWith('/api/auth-requests')) { if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
return jsonResponse({ data: [], object: 'list', continuationToken: null }); if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
if (authRequestMatch) {
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/collections' || path.startsWith('/api/collections/')) { if (path === '/api/collections' || path.startsWith('/api/collections/')) {
+26 -15
View File
@@ -18,8 +18,14 @@ import {
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth, handleUpdateDeviceWebPushAuth,
handleClearDeviceToken, handleClearDeviceToken,
handleRegisterDevice,
handleReportLostTrust,
} from './handlers/devices'; } from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
request: Request, request: Request,
env: Env, env: Env,
@@ -27,31 +33,36 @@ export async function handleAuthenticatedDeviceRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/api/devices') { if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'POST') return handleRegisterDevice(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null; return null;
} }
if (path === '/api/devices/authorized') { if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
return handleReportLostTrust(request, env, userId);
}
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null; return null;
} }
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') { if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
} }
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
if (permanentAuthorizedDeviceMatch && method === 'POST') { if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); 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);
@@ -61,59 +72,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);
} }
+99 -10
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import { import {
handleAccessSend, handleAccessSend,
handleAccessSendFile, handleAccessSendFile,
@@ -8,6 +7,11 @@ import {
handleDownloadSendFile, handleDownloadSendFile,
} from './handlers/sends'; } from './handlers/sends';
import { handleKnownDevice } from './handlers/devices'; import { handleKnownDevice } from './handlers/devices';
import {
handleDigitalAssetLinkCheck,
handleFillAssistForms,
handleFillAssistManifest,
} from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys'; import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import { import {
@@ -15,25 +19,38 @@ import {
handleGetPasswordHint, handleGetPasswordHint,
handleRecoverTwoFactor, handleRecoverTwoFactor,
} from './handlers/accounts'; } from './handlers/accounts';
import {
handleCreateAuthRequest,
handleGetAuthRequestResponse,
} from './handlers/auth-requests';
import { handlePublicDownloadAttachment } from './handlers/attachments'; import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments'; import { handlePublicUploadAttachment } from './handlers/attachments';
import { import {
handleAnonymousNotificationsHub,
handleNotificationsHub, handleNotificationsHub,
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { jsonResponse } from './utils/response'; import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations: number; defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason; jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number; jwtSecretMinLength: number;
registrationInviteRequired: boolean; registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
websiteIconsEnabled: boolean;
}
function isWebsiteIconProxyEnabled(env: Env): boolean {
return true;
} }
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
@@ -91,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
} }
function buildConfigResponse(origin: string) { function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return { return {
version: LIMITS.compatibility.bitwardenServerVersion, version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden', gitHash: 'nodewarden',
@@ -103,7 +121,7 @@ function buildConfigResponse(origin: string) {
notifications: origin + '/notifications', notifications: origin + '/notifications',
icons: origin, icons: origin,
sso: '', sso: '',
fillAssistRules: null, fillAssistRules: fillAssistBase,
}, },
push: { push: {
pushTechnology: 0, pushTechnology: 0,
@@ -119,8 +137,11 @@ function buildConfigResponse(origin: string) {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled, 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true, 'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
'web-push': false, 'web-push': false,
}, },
@@ -236,11 +257,16 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> { async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
if (!isWebsiteIconProxyEnabled(env)) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
const normalizedHost = normalizeIconHost(host); const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -267,7 +293,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers); const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
@@ -296,8 +322,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
? 'missing' ? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength : secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short' ? 'too_short'
: null; : null;
@@ -309,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason, jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0, registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
}; };
} }
@@ -335,12 +361,31 @@ export async function handlePublicRoute(
return jsonResponse(await buildWebBootstrapResponse(env)); return jsonResponse(await buildWebBootstrapResponse(env));
} }
if (path === '/fill-assist/manifest.json' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistManifest();
}
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleDigitalAssetLinkCheck();
}
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
if (fillAssistFormsMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistForms(fillAssistFormsMatch[1]);
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
} }
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
@@ -390,6 +435,19 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]); return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
} }
if ((path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleCreateAuthRequest(request, env);
}
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
}
if (path === '/identity/connect/token' && method === 'POST') { if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env); return handleToken(request, env);
} }
@@ -430,9 +488,34 @@ export async function handlePublicRoute(
} }
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
const publicMailBackedPaths = new Set([
'/api/accounts/resend-new-device-otp',
'/accounts/resend-new-device-otp',
'/api/accounts/register/send-verification-email',
'/accounts/register/send-verification-email',
'/identity/accounts/register/send-verification-email',
'/api/accounts/register/verification-email-clicked',
'/accounts/register/verification-email-clicked',
'/identity/accounts/register/verification-email-clicked',
'/api/accounts/register/finish',
'/accounts/register/finish',
'/identity/accounts/register/finish',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
]);
if (publicMailBackedPaths.has(path) && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return unsupportedResponse('Email delivery is not supported by this server.');
}
if (path === '/api/accounts/password-hint' && method === 'POST') { if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
@@ -449,7 +532,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin)); return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
@@ -477,5 +560,11 @@ export async function handlePublicRoute(
if (path === '/notifications/hub' && method === 'GET') { if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env); return handleNotificationsHub(request, env);
} }
if (path === '/notifications/anonymous-hub' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleAnonymousNotificationsHub(request, env);
}
return null; return null;
} }
+91 -18
View File
@@ -1,4 +1,4 @@
import { DEFAULT_DEV_SECRET, Env } from './types'; import { Env } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response'; import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,25 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated'; import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public'; import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && (path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check')) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean { function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false; if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -26,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
return false; return false;
} }
const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function isLargeUploadPath(path: string): boolean {
return (
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import'
);
}
async function enforceRequestBodyLimit(
request: Request,
path: string,
method: string
): Promise<Request | Response> {
if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) {
return request;
}
const contentLengthRaw = request.headers.get('Content-Length');
if (contentLengthRaw) {
const contentLength = Number(contentLengthRaw);
if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
if (Number.isFinite(contentLength) && contentLength >= 0) {
return request;
}
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > LIMITS.request.maxBodyBytes) {
try {
await reader.cancel();
} catch {
// Ignore cancellation races after the oversized body is rejected.
}
return errorResponse('Request body too large', 413);
}
chunks.push(value);
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new Request(request.url, {
method: request.method,
headers: request.headers,
body,
redirect: request.redirect,
});
}
export async function handleRequest(request: Request, env: Env): Promise<Response> { export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
@@ -50,7 +125,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests); const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
const check = shouldUseStrictBudget
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null; if (check.allowed) return null;
return new Response( return new Response(
@@ -70,29 +148,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
return handleCors(request); return handleCors(request, env);
} }
try { try {
const isLargeUploadPath = const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) || if (bodyLimitResult instanceof Response) {
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) || return bodyLimitResult;
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
} }
request = bodyLimitResult;
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit); const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse; if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
const auth = new AuthService(env); const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader); const verified = await auth.verifyAccessTokenWithUser(authHeader);
+90 -9
View File
@@ -1,4 +1,4 @@
import { zipSync, unzipSync } from 'fflate'; import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types'; import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version'; import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
@@ -28,10 +28,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Prefer store-only ZIP entries over heavier compression to keep exports reliable. // Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0; const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2; const BACKUP_JSON_INDENT = 2;
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024; export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000; const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
export interface BackupManifest { export interface BackupManifest {
formatVersion: 1; formatVersion: 1;
@@ -68,6 +69,7 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -185,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
} }
} }
function isSafeBackupPathSegment(value: string): boolean {
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
if (value === '.' || value === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(value);
}
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
const normalized = String(value ?? '').trim();
const parts = normalized.split('/');
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
}
function isSafeBackupAttachmentEntryName(value: string): boolean {
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
const relative = value.slice('attachments/'.length, -'.bin'.length);
return isSafeBackupAttachmentBlobName(relative);
}
function validateBackupEntryName(name: string): void {
const normalized = String(name || '').trim();
if (normalized !== name || !normalized) {
throw new Error('Backup archive contains an invalid file name');
}
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
}
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
}
}
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalOriginalBytes = 0;
return (file: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
validateBackupEntryName(file.name);
const originalSize = Number(file.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
}
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
totalOriginalBytes += originalSize;
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
return true;
};
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] { function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = []; const entries: string[] = [];
for (const row of db.attachments) { for (const row of db.attachments) {
@@ -222,8 +279,11 @@ export function parseBackupArchive(
validateArchiveSize(bytes); validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>; let zipped: Record<string, Uint8Array>;
try { try {
zipped = unzipSync(bytes); zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
} catch { } catch (error) {
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
throw error;
}
throw new Error('Invalid backup archive'); throw new Error('Invalid backup archive');
} }
@@ -234,6 +294,7 @@ export function parseBackupArchive(
let totalExtractedBytes = 0; let totalExtractedBytes = 0;
for (const entry of entryNames) { for (const entry of entryNames) {
validateBackupEntryName(entry);
const entryBytes = zipped[entry]; const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength; totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) { if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
@@ -302,6 +363,7 @@ export function validateBackupPayloadContents(
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 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`)
@@ -366,7 +428,7 @@ export function validateBackupPayloadContents(
for (const row of attachmentRows) { for (const row of attachmentRows) {
const id = String(row.id || '').trim(); const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim(); const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) { if (!id || !cipherId || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(cipherId) || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row'); throw new Error('Backup archive contains an invalid attachment row');
} }
const attachmentPath = `attachments/${cipherId}/${id}.bin`; const attachmentPath = `attachments/${cipherId}/${id}.bin`;
@@ -380,9 +442,10 @@ export function validateBackupPayloadContents(
for (const row of accountPasskeyRows) { for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim(); const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim(); const userId = String(row.user_id || '').trim();
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
const credentialId = String(row.credential_id || '').trim(); const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim(); const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) { if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
throw new Error('Backup archive contains an invalid account passkey row'); throw new Error('Backup archive contains an invalid account passkey row');
} }
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`); if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
@@ -390,6 +453,21 @@ export function validateBackupPayloadContents(
accountPasskeyIds.add(id); accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId); 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(
@@ -408,15 +486,16 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -445,6 +524,7 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -468,6 +548,7 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+253 -17
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,7 +26,9 @@ import {
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
export const REDACTED_BACKUP_SECRET = '********';
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
export type { export type {
@@ -35,6 +38,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
@@ -64,6 +68,114 @@ function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
} }
function normalizeHostnameForPolicy(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function parseIpv4Address(hostname: string): number[] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return -1;
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
});
return octets.every((value) => value >= 0) ? octets : null;
}
function isBlockedIpv4Address(octets: number[]): boolean {
const [a, b, c] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && (b === 0 || b === 168)) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224
);
}
function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase();
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets);
}
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16);
if (!Number.isFinite(firstHextet)) return true;
return (
firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:')
);
}
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
const normalized = normalizeHostnameForPolicy(hostname);
if (!normalized) throw new Error(`${label} host is required`);
if (
normalized === 'localhost' ||
normalized === 'localhost.localdomain' ||
normalized.endsWith('.localhost.localdomain') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.home.arpa') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.lan') ||
normalized === 'metadata.google.internal' ||
normalized === 'localtest.me' ||
normalized.endsWith('.localtest.me') ||
normalized === 'lvh.me' ||
normalized.endsWith('.lvh.me') ||
normalized === 'vcap.me' ||
normalized.endsWith('.vcap.me') ||
normalized === 'nip.io' ||
normalized.endsWith('.nip.io') ||
normalized === 'sslip.io' ||
normalized.endsWith('.sslip.io') ||
normalized === 'xip.io' ||
normalized.endsWith('.xip.io')
) {
throw new Error(`${label} host is not allowed`);
}
const ipv4 = parseIpv4Address(normalized);
if (ipv4 && isBlockedIpv4Address(ipv4)) {
throw new Error(`${label} host is not allowed`);
}
if (isBlockedIpv6Address(normalized)) {
throw new Error(`${label} host is not allowed`);
}
}
export function normalizeBackupEndpointUrl(value: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${label} must start with http:// or https://`);
}
if (parsed.username || parsed.password) {
throw new Error(`${label} must not include credentials`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${label} must not include query or fragment`);
}
assertBackupEndpointHostAllowed(parsed.hostname, label);
return parsed.toString().replace(/\/+$/, '');
}
function assertValidTimeZone(timezone: string): string { function assertValidTimeZone(timezone: string): string {
try { try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()); new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
@@ -109,6 +221,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';
@@ -116,7 +231,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
if (!allowIncomplete || endpoint) { if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('S3 endpoint is required'); if (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://'); normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
} }
if (!allowIncomplete || bucket) { if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('S3 bucket is required'); if (!bucket) throw new Error('S3 bucket is required');
@@ -129,8 +244,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
} }
return { return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket, bucket,
addressingStyle,
region, region,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
@@ -147,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
if (!allowIncomplete || baseUrl) { if (!allowIncomplete || baseUrl) {
if (!baseUrl) throw new Error('WebDAV server URL is required'); if (!baseUrl) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://'); normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
} }
if (!allowIncomplete || username) { if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required'); if (!username) throw new Error('WebDAV username is required');
@@ -157,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
} }
return { return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '', baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
username, username,
password, password,
remotePath, remotePath,
@@ -173,6 +289,32 @@ function normalizeDestination(
return normalizeWebDavDestination(destination, allowIncomplete); return normalizeWebDavDestination(destination, allowIncomplete);
} }
function shouldPreserveBackupSecret(value: unknown): boolean {
if (value === undefined || value === null) return true;
const raw = String(value);
return raw === '' || raw === REDACTED_BACKUP_SECRET;
}
function withPreservedDestinationSecret(
destinationType: BackupDestinationType,
inputDestination: unknown,
previous: BackupDestinationRecord | undefined
): unknown {
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
if (destinationType === 's3') {
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
source.secretAccessKey = previousDestination?.secretAccessKey || '';
}
} else {
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
if (shouldPreserveBackupSecret(source.password)) {
source.password = previousDestination?.password || '';
}
}
return source;
}
function normalizeRuntime(value: unknown): BackupRuntimeState { function normalizeRuntime(value: unknown): BackupRuntimeState {
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => { const asIso = (input: unknown): string | null => {
@@ -243,7 +385,11 @@ function normalizeDestinationRecord(
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
}; };
const destination = normalizeDestination(type, input.destination, !schedule.enabled); const destination = normalizeDestination(
type,
withPreservedDestinationSecret(type, input.destination, previous),
!schedule.enabled
);
return { return {
id, id,
@@ -318,6 +464,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 +568,55 @@ export function normalizeBackupSettingsInput(
} }
export function serializeBackupSettings(settings: BackupSettings): string { export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings); return JSON.stringify(stripRuntimeFromSettings(settings));
}
export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => {
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
return {
...destination,
destination: {
...config,
secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '',
},
};
}
const config = destination.destination as WebDavBackupDestination;
return {
...destination,
destination: {
...config,
password: config.password ? REDACTED_BACKUP_SECRET : '',
},
};
}),
};
} }
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) { if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone); const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
const envelope = parseBackupSettingsEnvelope(raw); const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) { if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone); return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch { } catch {
throw new Error('Backup settings need administrator reactivation after restore'); throw new Error('Backup settings need administrator reactivation after restore');
} }
@@ -411,6 +626,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
await saveBackupRuntimeStates(storage, settings);
}
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
}
export async function updateBackupDestinationRuntime(
storage: StorageService,
destinationId: string,
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
): Promise<BackupRuntimeState> {
const runtimes = await loadBackupRuntimeStates(storage);
const current = runtimes.get(destinationId) || normalizeRuntime(null);
const next = normalizeRuntime(mutator(current));
runtimes.set(destinationId, next);
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
version: 1,
destinations: Object.fromEntries(runtimes.entries()),
}));
return next;
} }
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> { export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
@@ -590,9 +826,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);
@@ -614,7 +850,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;
} }
} }
@@ -631,9 +867,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(
@@ -646,7 +882,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;
+47 -6
View File
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
import { import {
type BackupManifestAttachmentBlob, type BackupManifestAttachmentBlob,
type BackupPayload, type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive, parseBackupArchive,
validateBackupPayloadContents, validateBackupPayloadContents,
} from './backup-archive'; } from './backup-archive';
@@ -24,6 +25,7 @@ type BackupTableName =
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -34,6 +36,7 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -51,6 +54,7 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -172,6 +176,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', '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',
@@ -249,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row })); return rows.map((row) => ({ ...row }));
} }
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] { function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false; let replaced = false;
const nextRows = rows.map((row) => { const nextRows = rows.map((row) => {
@@ -293,10 +302,15 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 1,
yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []), trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose),
})),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row, ...row,
@@ -456,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
} }
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> { function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map( const lookup = new Map<string, BackupManifestAttachmentBlob>();
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item]) for (const item of manifest.attachmentBlobs || []) {
); const cipherId = String(item.cipherId || '').trim();
const attachmentId = String(item.attachmentId || '').trim();
const blobName = String(item.blobName || '').trim();
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
lookup.set(`${cipherId}/${attachmentId}`, {
...item,
cipherId,
attachmentId,
blobName,
});
}
return lookup;
} }
async function prepareRemoteAttachmentPayload( async function prepareRemoteAttachmentPayload(
@@ -614,7 +639,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements( buildInsertStatements(
db, db,
tableName('users'), tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
payload.users || [] payload.users || []
) )
); );
@@ -634,13 +659,23 @@ 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( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
buildInsertStatements( buildInsertStatements(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'], ['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || [] payload.webauthn_credentials || []
) )
); );
@@ -712,6 +747,7 @@ 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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -735,6 +771,7 @@ 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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -776,6 +813,7 @@ 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, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -853,6 +891,7 @@ 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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -876,6 +915,7 @@ 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, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -923,6 +963,7 @@ 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, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+75 -16
View File
@@ -3,6 +3,7 @@ import {
BackupDestinationType, BackupDestinationType,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
normalizeBackupEndpointUrl,
} from './backup-config'; } from './backup-config';
export interface BackupUploadResult { export interface BackupUploadResult {
@@ -33,6 +34,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array; bytes: Uint8Array;
} }
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions { export interface RemoteBackupFilePutOptions {
contentType?: string; contentType?: string;
} }
@@ -208,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 'webdav') { if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination; const config = destination.destination as WebDavBackupDestination;
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required'); if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://'); normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required'); if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required'); if (!String(config.password || '')) throw new Error('WebDAV password is required');
return; return;
@@ -216,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 's3') { if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination; const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required'); if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://'); normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required'); if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required'); if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required'); if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
@@ -245,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue; if ([200, 201, 204, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`); throw new Error(`WebDAV directory creation failed: ${response.status}`);
} }
} }
@@ -268,7 +276,7 @@ async function ensureWebDavDirectoryCached(
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if ([200, 201, 204, 301, 302, 405].includes(response.status)) { if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current); ensuredDirectories.add(current);
continue; continue;
} }
@@ -433,6 +441,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
} }
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> { async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
return (await statWebDavFile(config, relativePath)) !== null;
}
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password); const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath); const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,15 +453,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 {
@@ -482,7 +519,7 @@ async function signedS3Request(
config.region || 'auto' config.region || 'auto'
); );
return fetch(url.toString(), { return fetch(url, {
method, method,
headers: { headers: {
Authorization: authorization, Authorization: authorization,
@@ -501,7 +538,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,10 +558,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath); const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
do {
const url = s3BucketBaseUrl(config); const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2'); url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/'); url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
const response = await signedS3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
@@ -532,8 +575,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
} }
const xml = await response.text(); const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
@@ -577,6 +618,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
}); });
} }
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
} while (continuationToken);
const deduped = new Map<string, RemoteBackupItem>(); const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
@@ -594,7 +638,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 +654,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 +662,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
} }
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
return (await statS3File(config, relativePath)) !== null;
}
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = 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 +691,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -647,6 +702,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>; download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>; deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>; exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
} }
function resolveConfiguredDestinationAdapter( function resolveConfiguredDestinationAdapter(
@@ -664,6 +720,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 's3') { if (destination.type === 's3') {
@@ -676,6 +733,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
}; };
} }
@@ -711,6 +769,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
download: async (relativePath: string) => adapter.download(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
}; };
} }
+258
View File
@@ -0,0 +1,258 @@
import type { Env } from '../types';
import {
setConfigValue as saveConfigValue,
} from './storage-config-repo';
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
const PUSH_REQUEST_TIMEOUT_MS = 5000;
interface CachedPushAccessToken {
token: string;
expiresAt: number;
}
let cachedPushAccessToken: CachedPushAccessToken | null = null;
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
console.error(errorMessage, error);
return null;
} finally {
clearTimeout(timeout);
}
}
function randomInstallationEmail(): string {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
return `${localPart}@nodewarden.app`;
}
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const [id, key] = await Promise.all([
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
]);
const normalizedId = String(id || '').trim();
const normalizedKey = String(key || '').trim();
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
}
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const existing = await getPushInstallationCredentials(db);
if (existing) return existing;
const response = await fetchPushEndpoint(
INSTALLATIONS_URI,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({
email: randomInstallationEmail(),
}),
},
'Failed to request Bitwarden push installation:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
const id = String(body?.id || body?.Id || '').trim();
const key = String(body?.key || body?.Key || '').trim();
if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key');
return null;
}
await Promise.all([
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
]);
return { id, key };
}
async function getPushAccessToken(env: Env): Promise<string | null> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return null;
const now = Date.now();
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
return cachedPushAccessToken.token;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api.push',
client_id: `installation.${credentials.id}`,
client_secret: credentials.key,
});
const response = await fetchPushEndpoint(
`${PUSH_IDENTITY_URI}/connect/token`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
},
'Failed to get Bitwarden push relay token:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
const token = String(body?.access_token || '').trim();
if (!token) {
console.error('Bitwarden push relay token response did not include an access_token');
return null;
}
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
cachedPushAccessToken = {
token,
expiresAt: now + Math.floor(expiresInSeconds * 500),
};
return token;
}
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
const token = await getPushAccessToken(env);
if (!token) return false;
const response = await fetchPushEndpoint(
`${PUSH_RELAY_URI}${path}`,
{
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
},
`Bitwarden push relay request failed: ${path}`
);
if (!response) return false;
if (!response.ok) {
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
return false;
}
return true;
}
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
const source = payload || {};
const id = source.Id ?? source.id;
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
if (id != null) {
return {
id,
userId: source.UserId ?? source.userId ?? userId,
organizationId,
collectionIds,
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
};
}
return {
userId: source.UserId ?? source.userId ?? userId,
date: source.Date ?? source.date ?? revisionDate,
};
}
export async function registerMobilePushDevice(
env: Env,
input: {
userId: string;
deviceIdentifier: string;
type: number;
pushUuid: string;
pushToken: string;
}
): Promise<boolean> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return false;
return postToPushRelay(env, '/push/register', {
deviceId: input.pushUuid,
pushToken: input.pushToken,
userId: input.userId,
type: input.type,
identifier: input.deviceIdentifier,
installationId: credentials.id,
});
}
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, '/push/delete', { id: normalized });
}
export async function notifyMobilePush(
env: Env,
input: {
userId: string;
updateType: number;
revisionDate: string;
contextId: string | null;
payload: Record<string, unknown> | null | undefined;
}
): Promise<void> {
const hasPushDevice = await env.DB
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(input.userId, '')
.first<{ '1': number }>();
if (!hasPushDevice) return;
let actingPushUuid: string | null = null;
if (input.contextId) {
const row = await env.DB
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(input.userId, input.contextId)
.first<{ push_uuid: string | null }>();
actingPushUuid = row?.push_uuid ?? null;
}
await postToPushRelay(env, '/push/send', {
userId: input.userId,
organizationId: null,
deviceId: actingPushUuid,
identifier: input.contextId,
type: input.updateType,
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
clientType: null,
installationId: null,
});
}
+86
View File
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
// Rate limiting service. // Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence). // - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes). // - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints.
const CONFIG = { const CONFIG = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts, LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
@@ -12,11 +13,14 @@ const CONFIG = {
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false; private static loginIpTableReady = false;
private static strictBudgetTableReady = false;
private static lastLoginIpCleanupAt = 0; private static lastLoginIpCleanupAt = 0;
private static lastStrictBudgetCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs; private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs; private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -58,6 +62,35 @@ export class RateLimitService {
RateLimitService.loginIpTableReady = true; RateLimitService.loginIpTableReady = true;
} }
private async ensureStrictBudgetTable(): Promise<void> {
if (RateLimitService.strictBudgetTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' +
'bucket_key TEXT PRIMARY KEY, ' +
'count INTEGER NOT NULL, ' +
'expires_at INTEGER NOT NULL, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
await this.db
.prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)')
.run();
RateLimitService.strictBudgetTableReady = true;
}
private async maybeCleanupStrictBudgets(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run();
RateLimitService.lastStrictBudgetCleanupAt = nowMs;
}
async checkLoginAttempt(ip: string): Promise<{ async checkLoginAttempt(ip: string): Promise<{
allowed: boolean; allowed: boolean;
remainingAttempts: number; remainingAttempts: number;
@@ -174,6 +207,59 @@ export class RateLimitService {
return { allowed: true, remaining: Math.max(0, maxRequests - count) }; return { allowed: true, remaining: Math.max(0, maxRequests - count) };
} }
async consumeStrictBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
async consumeStrictBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
await this.ensureStrictBudgetTable();
const key = String(identifier || '').trim() || 'unknown';
const max = Math.max(1, Math.floor(maxRequests));
const windowSize = Math.max(1, Math.floor(windowSeconds));
const nowMs = Date.now();
const nowSec = Math.floor(nowMs / 1000);
const windowStart = nowSec - (nowSec % windowSize);
const windowEndMs = (windowStart + windowSize) * 1000;
const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000));
const bucketKey = `${key}:${windowStart}`;
await this.maybeCleanupStrictBudgets(nowMs);
await this.db
.prepare(
'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)'
)
.bind(bucketKey, windowEndMs, nowMs)
.run();
const update = await this.db
.prepare(
'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' +
'WHERE bucket_key = ? AND count < ?'
)
.bind(windowEndMs, nowMs, bucketKey, max)
.run();
const allowed = Number(update.meta?.changes ?? 0) > 0;
const row = await this.db
.prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?')
.bind(bucketKey)
.first<{ count: number }>();
const count = Math.max(0, Number(row?.count || 0));
if (!allowed) {
return { allowed: false, remaining: 0, retryAfterSeconds };
}
return { allowed: true, remaining: Math.max(0, max - count) };
}
// General-purpose fixed-window budget. // General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the // Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised // per-window maximum. This single method replaces all previous specialised
+21 -14
View File
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [ const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
{ name: 'id', sql: 'id TEXT' }, { name: 'id', sql: 'id TEXT' },
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" }, { name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" }, { name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" }, { name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" }, { name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
await db await db
.prepare( .prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + "id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)' 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
function mapCredentialRow(row: { function mapCredentialRow(row: {
id: string; id: string;
user_id: string; user_id: string;
purpose?: string | null;
name: string; name: string;
public_key: string; public_key: string;
credential_id: string; credential_id: string;
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
name: row.name, name: row.name,
publicKey: row.public_key, publicKey: row.public_key,
credentialId: row.credential_id, credentialId: row.credential_id,
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
await safeBind( await safeBind(
db.prepare( db.prepare(
'INSERT INTO webauthn_credentials(' + 'INSERT INTO webauthn_credentials(' +
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' + 'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' + 'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' + 'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' + 'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at' 'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
), ),
credential.id, credential.id,
credential.userId, credential.userId,
credential.purpose,
credential.name, credential.name,
credential.publicKey, credential.publicKey,
credential.credentialId, credential.credentialId,
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
export async function listAccountPasskeyCredentialsByUserId( export async function listAccountPasskeyCredentialsByUserId(
db: D1Database, db: D1Database,
userId: string userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> { ): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const rows = await db const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC') .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
.bind(userId) .bind(userId, purpose)
.all<any>(); .all<any>();
return (rows.results || []).map(mapCredentialRow); return (rows.results || []).map(mapCredentialRow);
} }
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
export async function countAccountPasskeyCredentialsByUserId( export async function countAccountPasskeyCredentialsByUserId(
db: D1Database, db: D1Database,
userId: string userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> { ): Promise<number> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const row = await db const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?') .prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
.bind(userId) .bind(userId, purpose)
.first<{ count: number }>(); .first<{ count: number }>();
return Number(row?.count || 0); return Number(row?.count || 0);
} }
@@ -262,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
const result = await db const result = await db
.prepare( .prepare(
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' + 'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
'WHERE user_id = ? AND credential_id = ?' "WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
) )
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId) .bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run(); .run();
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
export async function deleteAccountPasskeyCredential( export async function deleteAccountPasskeyCredential(
db: D1Database, db: D1Database,
userId: string, userId: string,
id: string id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> { ): Promise<boolean> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const result = await db const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?') .prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
.bind(userId, id) .bind(userId, id, purpose)
.run(); .run();
return Number(result.meta.changes || 0) > 0; return Number(result.meta.changes || 0) > 0;
} }
+36 -4
View File
@@ -117,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> { export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") .prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code) .bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
const result = await db
.prepare('DELETE FROM invites WHERE code = ?')
.bind(code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
const now = new Date().toISOString();
const result = await db
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
.bind(now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteAllInvites(db: D1Database): Promise<number> { export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run(); const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
+64 -1
View File
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
}; };
} }
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
const row = await db
.prepare(
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id
WHERE a.id = ? AND c.user_id = ?`
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
}
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> { export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key ' +
'WHERE EXISTS (' +
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
')'
); );
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
} }
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
} }
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
await db
.prepare(
`DELETE FROM attachments
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers c
WHERE c.id = attachments.cipher_id AND c.user_id = ?
)`
)
.bind(id, userId)
.run();
}
export async function bulkDeleteAttachmentsByIds( export async function bulkDeleteAttachmentsByIds(
db: D1Database, db: D1Database,
sqlChunkSize: SqlChunkSize, sqlChunkSize: SqlChunkSize,
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
} }
export async function addAttachmentToCipherForUser(
db: D1Database,
cipherId: string,
attachmentId: string,
userId: string
): Promise<void> {
await db
.prepare(
`UPDATE attachments
SET cipher_id = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers target_cipher
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
)
AND EXISTS (
SELECT 1 FROM ciphers current_cipher
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
)`
)
.bind(cipherId, attachmentId, cipherId, userId, userId)
.run();
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
} }
+144
View File
@@ -0,0 +1,144 @@
import type { AuthRequestRecord, AuthRequestType } from '../types';
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
function mapAuthRequestRow(row: any): AuthRequestRecord {
return {
id: row.id,
userId: row.user_id,
organizationId: row.organization_id ?? null,
type: Number(row.type) as AuthRequestType,
requestDeviceIdentifier: row.request_device_identifier,
requestDeviceType: Number(row.request_device_type ?? 14),
requestIpAddress: row.request_ip_address ?? null,
requestCountryName: row.request_country_name ?? null,
responseDeviceIdentifier: row.response_device_identifier ?? null,
accessCode: row.access_code,
publicKey: row.public_key,
key: row.key ?? null,
masterPasswordHash: row.master_password_hash ?? null,
approved: row.approved == null ? null : Number(row.approved) === 1,
creationDate: row.creation_date,
responseDate: row.response_date ?? null,
authenticationDate: row.authentication_date ?? null,
};
}
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
}
const AUTH_REQUEST_SELECT =
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
'FROM auth_requests';
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
await db
.prepare(
'INSERT INTO auth_requests(' +
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(
request.id,
request.userId,
request.organizationId,
request.type,
request.requestDeviceIdentifier,
request.requestDeviceType,
request.requestIpAddress,
request.requestCountryName,
request.responseDeviceIdentifier,
request.accessCode,
request.publicKey,
request.key,
request.masterPasswordHash,
request.approved == null ? null : (request.approved ? 1 : 0),
request.creationDate,
request.responseDate,
request.authenticationDate
)
.run();
}
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
return (res.results || []).map(mapAuthRequestRow);
}
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
const res = await db
.prepare(
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
'FROM auth_requests ar ' +
'JOIN (' +
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
' FROM auth_requests ' +
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
' GROUP BY request_device_identifier' +
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
'ORDER BY ar.creation_date DESC'
)
.bind(userId, cutoff, userId)
.all<any>();
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
}
export async function updateAuthRequestResponse(
db: D1Database,
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
responseDate?: string;
}
): Promise<boolean> {
const result = await db
.prepare(
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
)
.bind(
update.approved ? 1 : 0,
update.responseDeviceIdentifier,
update.approved ? (update.key ?? null) : null,
update.approved ? (update.masterPasswordHash ?? null) : null,
update.responseDate || new Date().toISOString(),
id,
userId
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
const result = await db
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
.bind(authenticationDate, id)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
return Number(result.meta.changes ?? 0);
}
+18 -4
View File
@@ -87,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);
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
return parseCipherRow(row); return parseCipherRow(row);
} }
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
.bind(id, userId)
.first<CipherRow>();
return parseCipherRow(row);
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId); const folderId = normalizeOptionalId(cipher.folderId);
const data = buildCipherData(cipher, folderId); const data = buildCipherData(cipher, folderId);
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at' 'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -244,7 +253,9 @@ export async function getCiphersPage(
limit: number, limit: number,
offset: number offset: number
): Promise<Cipher[]> { ): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; const whereDeleted = includeDeleted
? ''
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
const res = await db const res = await db
.prepare( .prepare(
`SELECT ${selectCipherColumns()} FROM ciphers `SELECT ${selectCipherColumns()} FROM ciphers
@@ -341,7 +352,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();
+80 -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,
@@ -91,6 +97,20 @@ export async function touchDeviceLastSeen(
return Number(result.meta.changes ?? 0) > 0; return Number(result.meta.changes ?? 0) > 0;
} }
export async function rotateDeviceSessionStamp(
db: D1Database,
userId: string,
deviceIdentifier: string,
sessionStamp: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(sessionStamp, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys( export async function updateDeviceKeys(
db: D1Database, db: D1Database,
userId: string, userId: string,
@@ -166,7 +186,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 +197,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 +205,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 = ?')
+24 -5
View File
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
return mapFolderRow(row); return mapFolderRow(row);
} }
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapFolderRow(row);
}
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> { export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' 'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
) )
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run(); .run();
@@ -44,9 +53,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?` WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
) )
.bind(now, userId, folderId) .bind(now, userId, folderId, folderId, folderId)
.run(); .run();
} }
@@ -71,9 +85,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})` WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
) )
.bind(now, userId, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); .run();
await db await db
+29 -3
View File
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN api_key TEXT', 'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' + 'CREATE TABLE IF NOT EXISTS domain_settings (' +
@@ -78,6 +84,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + '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)',
@@ -94,7 +101,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE 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)',
@@ -103,22 +110,41 @@ 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 (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)', 'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
+20 -4
View File
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
return mapSendRow(row); return mapSendRow(row);
} }
export async function getSendForUser(db: D1Database, id: string, userId: string): Promise<Send | null> {
const row = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ? AND user_id = ?'
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapSendRow(row);
}
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> { export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' + 'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' + 'type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' + 'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' + 'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date' 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
@@ -81,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
const result = await db const result = await db
.prepare( .prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' + 'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)' 'WHERE id = ? ' +
'AND disabled = 0 ' +
'AND (max_access_count IS NULL OR access_count < max_access_count) ' +
'AND (expiration_date IS NULL OR expiration_date > ?) ' +
'AND deletion_date > ?'
) )
.bind(now, sendId) .bind(now, sendId, now, now)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
+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,
};
}
+24 -6
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS = const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + 'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, api_key, created_at, updated_at'; 'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
function mapUserRow(row: any): User { function mapUserRow(row: any): User {
return { return {
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null,
yubikeyKey2: row.yubikey_key2 ?? null,
yubikeyKey3: row.yubikey_key3 ?? null,
yubikeyKey4: row.yubikey_key4 ?? null,
yubikeyKey5: row.yubikey_key5 ?? null,
yubikeyNfc: !!row.yubikey_nfc,
apiKey: row.api_key ?? null, apiKey: row.api_key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
+181 -17
View File
@@ -1,5 +1,6 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } 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,
@@ -21,7 +22,10 @@ import {
type AuditLogListOptions, type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs, clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteInvite as deleteStoredInvite,
deleteInvalidInvites as deleteStoredInvalidInvites,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs, listAuditLogs as listStoredAuditLogs,
@@ -29,7 +33,7 @@ import {
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax, pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite, revertInviteUsed as revertStoredInviteUsed,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
bulkDeleteFolders as deleteStoredFolders, bulkDeleteFolders as deleteStoredFolders,
@@ -37,6 +41,7 @@ import {
deleteFolder as deleteStoredFolder, deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders, getAllFolders as listStoredFolders,
getFolder as findStoredFolder, getFolder as findStoredFolder,
getFolderForUser as findStoredFolderForUser,
getFoldersPage as listStoredFoldersPage, getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
@@ -49,6 +54,7 @@ import {
bulkUnarchiveCiphers as unarchiveStoredCiphers, bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCipherForUser as findStoredCipherForUser,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage, getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher, saveCipher as saveStoredCipher,
@@ -56,10 +62,13 @@ import {
} from './storage-cipher-repo'; } from './storage-cipher-repo';
import { import {
addAttachmentToCipher as attachStoredAttachmentToCipher, addAttachmentToCipher as attachStoredAttachmentToCipher,
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment, deleteAttachment as deleteStoredAttachment,
deleteAttachmentForUser as deleteStoredAttachmentForUser,
getAttachment as findStoredAttachment, getAttachment as findStoredAttachment,
getAttachmentForUser as findStoredAttachmentForUser,
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
@@ -71,6 +80,7 @@ import {
deleteSend as deleteStoredSend, deleteSend as deleteStoredSend,
getAllSends as listStoredSends, getAllSends as listStoredSends,
getSend as findStoredSend, getSend as findStoredSend,
getSendForUser as findStoredSendForUser,
getSendsByIds as listStoredSendsByIds, getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage, getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount, incrementSendAccessCount as incrementStoredSendAccessCount,
@@ -87,26 +97,44 @@ 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,
isKnownDevice as getKnownStoredDevice, isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
touchDeviceLastSeen as touchStoredDeviceLastSeen, touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import {
createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById,
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
updateAuthRequestResponse as updateStoredAuthRequestResponse,
} from './storage-auth-request-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -134,8 +162,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-09-account-passkeys'; const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -148,10 +176,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) {}
@@ -166,14 +197,14 @@ 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 hasAccountPasskeyTables(): Promise<boolean> { private async hasRequiredSchemaTables(): Promise<boolean> {
const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', '); const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
const result = await this.db const result = await this.db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`) .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
.bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES) .bind(...REQUIRED_SCHEMA_TABLES)
.all<{ name: string }>(); .all<{ name: string }>();
const found = new Set((result.results || []).map((row) => row.name)); const found = new Set((result.results || []).map((row) => row.name));
return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table)); return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
} }
private sqlChunkSize(fixedBindCount: number): number { private sqlChunkSize(fixedBindCount: number): number {
@@ -220,12 +251,13 @@ 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);
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
? !(await this.hasAccountPasskeyTables()) ? !(await this.hasRequiredSchemaTables())
: true; : true;
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) { 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;
} }
@@ -298,8 +330,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> {
@@ -355,8 +399,11 @@ export class StorageService {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential); await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
} }
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> { async getAccountPasskeyCredentialsByUserId(
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId); userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
} }
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> { async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
@@ -367,8 +414,11 @@ export class StorageService {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId); return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
} }
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> { async countAccountPasskeyCredentialsByUserId(
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId); userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
} }
async updateAccountPasskeyCounter( async updateAccountPasskeyCounter(
@@ -399,8 +449,12 @@ export class StorageService {
); );
} }
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> { async deleteAccountPasskeyCredential(
return deleteStoredAccountPasskeyCredential(this.db, userId, id); userId: string,
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
} }
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> { async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
@@ -422,6 +476,10 @@ export class StorageService {
return findStoredCipher(this.db, id); return findStoredCipher(this.db, id);
} }
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
return findStoredCipherForUser(this.db, id, userId);
}
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
} }
@@ -472,6 +530,10 @@ export class StorageService {
return findStoredFolder(this.db, id); return findStoredFolder(this.db, id);
} }
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
return findStoredFolderForUser(this.db, id, userId);
}
async saveFolder(folder: Folder): Promise<void> { async saveFolder(folder: Folder): Promise<void> {
await saveStoredFolder(this.db, folder); await saveStoredFolder(this.db, folder);
} }
@@ -510,6 +572,10 @@ export class StorageService {
return findStoredAttachment(this.db, id); return findStoredAttachment(this.db, id);
} }
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
return findStoredAttachmentForUser(this.db, id, userId);
}
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
} }
@@ -518,6 +584,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id); await deleteStoredAttachment(this.db, id);
} }
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
await deleteStoredAttachmentForUser(this.db, id, userId);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> { async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
} }
@@ -538,6 +608,10 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
@@ -598,6 +672,10 @@ export class StorageService {
return findStoredSend(this.db, id); return findStoredSend(this.db, id);
} }
async getSendForUser(id: string, userId: string): Promise<Send | null> {
return findStoredSendForUser(this.db, id, userId);
}
async saveSend(send: Send): Promise<void> { async saveSend(send: Send): Promise<void> {
await saveStoredSend(this.db, this.safeBind.bind(this), send); await saveStoredSend(this.db, this.safeBind.bind(this), send);
} }
@@ -684,6 +762,10 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier); return findStoredDevice(this.db, userId, deviceIdentifier);
} }
async rotateDeviceSessionStamp(userId: string, deviceIdentifier: string, sessionStamp: string): Promise<boolean> {
return rotateStoredDeviceSessionStamp(this.db, userId, deviceIdentifier, sessionStamp);
}
async updateDeviceKeys( async updateDeviceKeys(
userId: string, userId: string,
deviceIdentifier: string, deviceIdentifier: string,
@@ -704,6 +786,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);
} }
@@ -716,6 +819,49 @@ export class StorageService {
return deleteStoredDevicesByUserId(this.db, userId); return deleteStoredDevicesByUserId(this.db, userId);
} }
// --- Auth requests / Login with device ---
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
await createStoredAuthRequest(this.db, request);
}
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestById(this.db, id);
}
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestByIdForUser(this.db, id, userId);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId);
}
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredPendingAuthRequestsByUserId(this.db, userId);
}
async updateAuthRequestResponse(
id: string,
userId: string,
update: {
approved: boolean;
responseDeviceIdentifier: string;
key?: string | null;
masterPasswordHash?: string | null;
}
): Promise<boolean> {
return updateStoredAuthRequestResponse(this.db, id, userId, update);
}
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
return markStoredAuthRequestAuthenticated(this.db, id);
}
async pruneExpiredAuthRequests(): Promise<number> {
return pruneStoredExpiredAuthRequests(this.db);
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> { async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
return listStoredTrustedTokenSummaries(this.db, userId); return listStoredTrustedTokenSummaries(this.db, userId);
} }
@@ -748,6 +894,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> {
+118 -6
View File
@@ -14,15 +14,17 @@ export interface Env {
WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string; WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string; WEBAUTHN_ALLOWED_ORIGINS?: string;
YUBICO_CLIENT_ID?: string;
YUBICO_SECRET_KEY?: string;
YUBICO_VALIDATION_URLS?: string;
'globalSettings__yubico__clientId'?: string;
'globalSettings__yubico__key'?: string;
'globalSettings__yubico__validationUrls'?: string;
} }
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned'; export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
@@ -53,6 +55,12 @@ export interface User {
verifyDevices?: boolean; verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null; totpRecoveryCode: string | null;
yubikeyKey1: string | null;
yubikeyKey2: string | null;
yubikeyKey3: string | null;
yubikeyKey4: string | null;
yubikeyKey5: string | null;
yubikeyNfc: boolean;
apiKey: string | null; apiKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -116,6 +124,10 @@ export enum CipherType {
SecureNote = 2, SecureNote = 2,
Card = 3, Card = 3,
Identity = 4, Identity = 4,
SSHKey = 5,
BankAccount = 6,
DriversLicense = 7,
Passport = 8,
} }
export interface CipherLoginUri { export interface CipherLoginUri {
@@ -150,6 +162,52 @@ export interface CipherSshKey {
keyFingerprint: string; keyFingerprint: string;
} }
export interface CipherBankAccount {
bankName: string | null;
nameOnAccount: string | null;
accountType: string | null;
accountNumber: string | null;
routingNumber: string | null;
branchNumber: string | null;
pin: string | null;
swiftCode: string | null;
iban: string | null;
bankContactPhone: string | null;
[key: string]: any;
}
export interface CipherDriversLicense {
firstName: string | null;
middleName: string | null;
lastName: string | null;
dateOfBirth: string | null;
licenseNumber: string | null;
issuingCountry: string | null;
issuingState: string | null;
issueDate: string | null;
expirationDate: string | null;
issuingAuthority: string | null;
licenseClass: string | null;
[key: string]: any;
}
export interface CipherPassport {
surname: string | null;
givenName: string | null;
dateOfBirth: string | null;
sex: string | null;
birthPlace: string | null;
nationality: string | null;
issuingCountry: string | null;
passportNumber: string | null;
passportType: string | null;
nationalIdentificationNumber: string | null;
issuingAuthority: string | null;
issueDate: string | null;
expirationDate: string | null;
[key: string]: any;
}
export interface CipherIdentity { export interface CipherIdentity {
title: string | null; title: string | null;
firstName: string | null; firstName: string | null;
@@ -200,6 +258,9 @@ export interface Cipher {
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null; sshKey: CipherSshKey | null;
bankAccount?: CipherBankAccount | null;
driversLicense?: CipherDriversLicense | null;
passport?: CipherPassport | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
@@ -231,6 +292,8 @@ 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;
@@ -242,6 +305,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential { export interface AccountPasskeyCredential {
id: string; id: string;
userId: string; userId: string;
purpose: 'login' | 'twoFactor';
name: string; name: string;
publicKey: string; publicKey: string;
credentialId: string; credentialId: string;
@@ -257,7 +321,12 @@ export interface AccountPasskeyCredential {
updatedAt: string; updatedAt: string;
} }
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet'; export type AccountPasskeyChallengeScope =
| 'Authentication'
| 'CreateCredential'
| 'UpdateKeySet'
| 'TwoFactorAuthentication'
| 'TwoFactorCreate';
export interface AccountPasskeyChallenge { export interface AccountPasskeyChallenge {
challengeHash: string; challengeHash: string;
@@ -273,6 +342,28 @@ export interface DevicePendingAuthRequest {
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;
@@ -283,6 +374,7 @@ export interface DeviceResponse {
type: number; type: number;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
lastActivityDate?: string | null;
lastSeenAt?: string | null; lastSeenAt?: string | null;
hasStoredDevice?: boolean; hasStoredDevice?: boolean;
isTrusted: boolean; isTrusted: boolean;
@@ -442,8 +534,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;
@@ -467,17 +569,19 @@ export interface ProfileResponse {
masterPasswordHint: string | null; masterPasswordHint: string | null;
culture: string; culture: string;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
yubikeyEnabled?: boolean;
key: string; key: string;
privateKey: string | null; privateKey: string | null;
accountKeys: any | null; accountKeys: any | null;
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;
@@ -496,6 +600,9 @@ export interface CipherResponse {
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null; sshKey: CipherSshKey | null;
bankAccount: CipherBankAccount | null;
driversLicense: CipherDriversLicense | null;
passport: CipherPassport | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
@@ -536,6 +643,7 @@ 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;
@@ -543,6 +651,10 @@ export interface SyncResponse {
KeyConnectorOption?: null; KeyConnectorOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
V2UpgradeToken?: {
WrappedUserKey1: string;
WrappedUserKey2: string;
} | null;
Object?: string; Object?: string;
} | null; } | null;
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
+60 -30
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption, WebAuthnPrfDecryptionOption,
} from '../types'; } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey'; import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1'; const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000; const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
@@ -32,6 +33,44 @@ function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value); return new TextEncoder().encode(value);
} }
function hexByte(value: number): string {
return value.toString(16).padStart(2, '0');
}
function dotNetGuidBytesToUuid(bytes: Uint8Array): string | null {
if (bytes.length !== 16) return null;
return [
[bytes[3], bytes[2], bytes[1], bytes[0]].map(hexByte).join(''),
[bytes[5], bytes[4]].map(hexByte).join(''),
[bytes[7], bytes[6]].map(hexByte).join(''),
[bytes[8], bytes[9]].map(hexByte).join(''),
Array.from(bytes.slice(10, 16)).map(hexByte).join(''),
].join('-');
}
function uuidToDotNetGuidBytes(value: string): Uint8Array | null {
const match = String(value || '').trim().match(
/^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i
);
if (!match) return null;
const hex = match.slice(1).join('');
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i += 1) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return new Uint8Array([
bytes[3], bytes[2], bytes[1], bytes[0],
bytes[5], bytes[4],
bytes[7], bytes[6],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
]);
}
function normalizeWebAuthnBase64(value: unknown): string {
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function importHmacKey(secret: string): Promise<CryptoKey> { async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
} }
@@ -59,7 +98,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
} }
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number { export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS; return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
} }
export async function createAccountPasskeyToken( export async function createAccountPasskeyToken(
@@ -119,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim(); const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname; const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME; const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '') const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const origins = new Set<string>([url.origin, ...configuredOrigins]); const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) }; return { rpId, rpName, origins: Array.from(origins) };
} }
export function userIdToWebAuthnUserId(userId: string): Uint8Array { export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return textBytes(userId); return uuidToDotNetGuidBytes(userId) || textBytes(userId);
} }
export function userHandleToUserId(userHandle: string | undefined): string | null { export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null; if (!userHandle) return null;
try { try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle)); const bytes = base64UrlToBytes(userHandle);
const officialGuid = dotNetGuidBytesToUuid(bytes);
if (officialGuid) return officialGuid;
const decoded = new TextDecoder().decode(bytes);
return decoded.trim() || null; return decoded.trim() || null;
} catch { } catch {
return null; return null;
@@ -207,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
const clientDataJSON = response.clientDataJSON || response.clientDataJson; const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null; if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return { return {
id: String(input.id), id: normalizeWebAuthnBase64(input.id),
rawId: String(input.rawId), rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key', type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment, authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {}, clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: { response: {
attestationObject: String(response.attestationObject), attestationObject: normalizeWebAuthnBase64(response.attestationObject),
clientDataJSON: String(clientDataJSON), clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined, authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined, transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? String(response.publicKey) : undefined, publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined, publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
}, },
}; };
@@ -230,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
const clientDataJSON = response.clientDataJSON || response.clientDataJson; const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null; if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return { return {
id: String(input.id), id: normalizeWebAuthnBase64(input.id),
rawId: String(input.rawId), rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key', type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment, authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {}, clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: { response: {
authenticatorData: String(response.authenticatorData), authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
clientDataJSON: String(clientDataJSON), clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
signature: String(response.signature), signature: normalizeWebAuthnBase64(response.signature),
userHandle: response.userHandle ? String(response.userHandle) : undefined, userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
}, },
}; };
} }
+36
View File
@@ -0,0 +1,36 @@
const API_KEY_HASH_PREFIX = 'sha256:';
export function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
}
export async function hashApiKey(apiKey: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
}
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false;
const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored);
}
+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;
}
+12 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types'; import { Env } from '../types';
import { errorResponse } from './response'; import { errorResponse } from './response';
export interface DirectUploadPayload { export interface DirectUploadPayload {
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
fileNameMismatchMessage?: string; fileNameMismatchMessage?: string;
} }
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
export function buildDirectUploadUrl(request: Request, path: string, token: string): string { export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03'; const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z'; const expiresAt = '2099-12-31T23:59:59Z';
@@ -28,12 +30,16 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null { export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return null; return null;
} }
return secret; return secret;
} }
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
}
function parseContentLength(request: Request): number | null { function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length'); const raw = request.headers.get('content-length');
if (!raw) return null; if (!raw) return null;
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) { if (contentType.includes('multipart/form-data')) {
const declaredSize = parseContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) {
return errorResponse(tooLargeMessage, 413);
}
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('data') as File | null; const file = formData.get('data') as File | null;
if (!file) { if (!file) {
+42
View File
@@ -0,0 +1,42 @@
import type { Env } from '../types';
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>();
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption';
import { isYubiKeyEnabled } from './yubico-otp';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env;
const organizations: any[] = [];
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
yubikeyEnabled: isYubiKeyEnabled(user),
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations,
organizationsNew: organizations,
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
role: user.role,
status: user.status,
object: 'profile',
};
}
+27 -18
View File
@@ -1,4 +1,10 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [ const DEFAULT_CORS_HEADERS = [
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session', 'X-NodeWarden-Web-Session',
]; ];
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean { function isWildcardCorsPath(path: string): boolean {
return ( return (
path.startsWith('/icons/') path.startsWith('/icons/')
|| path.startsWith('/fill-assist/')
|| path === '/v1/assetlinks:check'
|| path === '/api/v1/assetlinks:check'
|| path === '/config' || path === '/config'
|| path === '/api/config' || path === '/api/config'
|| path === '/api/version' || path === '/api/version'
); );
} }
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } { function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url); const url = new URL(request.url);
const origin = request.headers.get('Origin'); const originHeader = request.headers.get('Origin');
if (!origin) { if (!originHeader) {
return isWildcardCorsPath(url.pathname) return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false } ? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false }; : { allowOrigin: null, allowCredentials: false };
} }
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) { if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isExtensionOrigin(origin)) { if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isWildcardCorsPath(url.pathname)) { if (isWildcardCorsPath(url.pathname)) {
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false }; return { allowOrigin: null, allowCredentials: false };
} }
function buildCorsHeaders(request: Request): Record<string, string> { function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '') const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',') .split(',')
.map((value) => value.trim()) .map((value) => value.trim())
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
}; };
const corsPolicy = getCorsPolicy(request); const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) { if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin; headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) { if (corsPolicy.allowCredentials) {
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors( export function applyCors(
request: Request, request: Request,
response: Response response: Response,
env: Env
): Response { ): Response {
// WebSocket upgrade responses must be returned untouched. // WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket; const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -92,7 +95,7 @@ export function applyCors(
} }
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request); const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) { for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v); headers.set(k, v);
} }
@@ -100,7 +103,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY'); headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
@@ -134,6 +139,10 @@ export function errorResponse(message: string, status: number = 400): Response {
); );
} }
export function unsupportedResponse(message: string = 'This feature is not supported by this server.'): Response {
return errorResponse(message, 501);
}
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
return jsonResponse( return jsonResponse(
@@ -150,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
} }
// Handle CORS preflight // Handle CORS preflight
export function handleCors(request: Request): Response { export function handleCors(request: Request, env: Env): Response {
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: buildCorsHeaders(request), headers: buildCorsHeaders(request, env),
}); });
} }
+16 -7
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 {
+1
View File
@@ -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',
+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;
}
}
+190
View File
@@ -0,0 +1,190 @@
import type { Env, User } from '../types';
const YUBIKEY_PUBLIC_ID_LENGTH = 12;
const YUBIKEY_MIN_OTP_LENGTH = 32;
const YUBIKEY_MAX_OTP_LENGTH = 48;
const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify';
const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/';
const MODHEX_RE = /^[cbdefghijklnrtuv]+$/;
export interface YubicoApiCredentials {
clientId: string;
secretKey: string;
}
export function normalizeYubiKeyOtp(input: string): string {
return String(input || '').replace(/\s+/g, '').toLowerCase();
}
export function yubiKeyPublicIdFromOtp(input: string): string | null {
const otp = normalizeYubiKeyOtp(input);
if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp;
if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null;
if (!MODHEX_RE.test(otp)) return null;
return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH);
}
export function isYubiKeyPublicId(input: string): boolean {
const value = normalizeYubiKeyOtp(input);
return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value);
}
function isYubiKeyOtp(input: string): boolean {
const otp = normalizeYubiKeyOtp(input);
return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp);
}
export function userYubiKeyPublicIds(user: User): string[] {
return [
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function isYubiKeyEnabled(user: User): boolean {
return userYubiKeyPublicIds(user).length > 0;
}
export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null {
const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim();
const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim();
return clientId ? { clientId, secretKey } : null;
}
function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function parseYubicoResponse(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) continue;
out[line.slice(0, idx)] = line.slice(idx + 1);
}
return out;
}
function base64ToBytes(input: string): Uint8Array {
const binary = atob(input);
const out = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
return out;
}
function bytesToBase64(input: Uint8Array): string {
let binary = '';
for (const byte of input) binary += String.fromCharCode(byte);
return btoa(binary);
}
async function hmacSha1Base64(base64Key: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
base64ToBytes(base64Key),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
}
function constantTimeStringEquals(a: string, b: string): boolean {
const aBytes = new TextEncoder().encode(a);
const bBytes = new TextEncoder().encode(b);
let diff = aBytes.length ^ bBytes.length;
for (let index = 0; index < aBytes.length && index < bBytes.length; index += 1) {
diff |= aBytes[index] ^ bBytes[index];
}
return diff === 0;
}
function canonicalQuery(params: URLSearchParams): string {
return Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('&');
}
function validationUrls(env: Env): string[] {
const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL];
}
export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise<YubicoApiCredentials | null> {
const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return null;
const body = new URLSearchParams();
body.set('email', String(email || '').trim().toLowerCase());
body.set('otp', otp);
body.set('terms_conditions', 'consented');
const response = await fetch(YUBICO_GET_API_KEY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) return null;
const html = await response.text();
const clientId = /Client ID:<\/th>\s*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
return clientId ? { clientId, secretKey } : null;
}
export async function verifyYubicoOtp(
env: Env,
otpInput: string,
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
): Promise<boolean> {
const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return false;
const clientId = String(credentials?.clientId || '').trim();
if (!clientId) return false;
const nonce = randomNonce();
const secretKey = String(credentials?.secretKey || '').trim();
const params = new URLSearchParams({
id: clientId,
nonce,
otp,
});
if (secretKey) {
try {
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
} catch {
return false;
}
}
for (const baseUrl of validationUrls(env)) {
try {
const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' });
if (!response.ok) continue;
const parsed = parseYubicoResponse(await response.text());
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
if (secretKey) {
if (!parsed.h) continue;
const signedParams = new URLSearchParams();
for (const [key, value] of Object.entries(parsed)) {
if (key !== 'h') signedParams.set(key, value);
}
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
}
return true;
} catch {
continue;
}
}
return false;
}
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

@@ -0,0 +1,422 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px 18px;
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.remember {
display: flex;
align-items: center;
gap: 9px;
color: #344054;
font-size: 14px;
}
.remember input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="title">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="title">Verify your identity</h1>
<p id="subtitle">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="msg" class="msg" role="status" aria-live="polite"></div>
<label class="remember">
<input id="remember" type="checkbox" />
<span id="remember-label">Trust this device for 30 days</span>
</label>
<button id="webauthn-button" type="button">Read security key</button>
</div>
</section>
</main>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
var titleEl = document.getElementById("title");
var subtitleEl = document.getElementById("subtitle");
var rememberEl = document.getElementById("remember");
var rememberLabelEl = document.getElementById("remember-label");
var buttonEl = document.getElementById("webauthn-button");
var msgEl = document.getElementById("msg");
titleEl.textContent = text.title;
subtitleEl.textContent = text.subtitle;
rememberLabelEl.textContent = text.remember;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
buttonEl.addEventListener("click", start);
function pickText(locale) {
var normalized = String(locale || "en").toLowerCase();
if (normalized.indexOf("zh") === 0) {
return {
title: "\u9a8c\u8bc1\u8eab\u4efd",
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
success: "\u9a8c\u8bc1\u5b8c\u6210",
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
};
}
return {
title: "Verify your identity",
subtitle: "Use your security key to finish two-step verification.",
remember: "Trust this device for 30 days",
button: "Read security key",
awaiting: "Awaiting security key interaction...",
success: "Verification complete",
unsupported: "This browser does not support security keys",
};
}
function decodeRepeated(value) {
if (!value) return "";
var out = String(value);
for (var i = 0; i < 2; i += 1) {
try {
var next = decodeURIComponent(out);
if (next === out) break;
out = next;
} catch (_error) {
break;
}
}
return out;
}
function normalizeOrigin(value) {
if (!value) return "";
try {
var url = new URL(value);
if (!url.protocol || !url.host) return "";
return url.protocol + "//" + url.host;
} catch (_error) {
return "";
}
}
function isExtensionOrigin(origin) {
return (
origin.indexOf("chrome-extension://") === 0 ||
origin.indexOf("moz-extension://") === 0 ||
origin.indexOf("safari-web-extension://") === 0
);
}
function allowedParentOrigins() {
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
headers: { Accept: "application/json" },
credentials: "omit",
}).then(function (response) {
if (!response.ok) return [];
return response.json();
}).then(function (body) {
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
? body.webAuthnAllowedOrigins
: [];
return origins.map(normalizeOrigin).filter(Boolean);
}).catch(function () {
return [];
});
return allowedParentOriginsPromise;
}
function trustedParentOrigin(allowedOrigins) {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
var parentOrigin = normalizeOrigin(parent);
if (!parentOrigin) return "";
if (parentOrigin === window.location.origin) {
return parentOrigin;
}
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
return parentOrigin;
}
return "";
}
function safeShallowCopy(source) {
var copy = {};
if (!source || typeof source !== "object") return copy;
Object.keys(source).forEach(function (key) {
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
copy[key] = source[key];
});
return copy;
}
async function postResult(message) {
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin);
}
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, parentOrigin);
}
}
window.postMessage(message, window.location.origin);
}
function showMessage(kind, message) {
msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind;
}
function decodeBase64Unicode(value) {
var input = String(value || "").replace(/ /g, "+");
try {
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
} catch (_error) {
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
}
}
function bytesFromBase64Url(value) {
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
var binary = atob(normalized);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function base64UrlFromBuffer(value) {
if (!value) return undefined;
var bytes = value instanceof Uint8Array
? value
: new Uint8Array(value);
var binary = "";
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function readPublicKeyOptions() {
var data = params.get("data");
if (!data) throw new Error("No data.");
var decoded = decodeBase64Unicode(data);
if (params.get("v") === "1") {
return JSON.parse(decoded);
}
var payload = JSON.parse(decoded);
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = safeShallowCopy(options);
copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
var next = safeShallowCopy(credential);
next.id = bytesFromBase64Url(credential && credential.id);
return next;
});
}
return copy;
}
function credentialToDataString(credential) {
var response = credential.response;
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
var data = {
id: credential.id,
rawId: base64UrlFromBuffer(credential.rawId),
type: credential.type,
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientDataJSON,
clientDataJSON: clientDataJSON,
signature: base64UrlFromBuffer(response.signature),
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
},
};
return JSON.stringify(data);
}
async function start() {
if (sentSuccess) return;
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
showMessage("error", text.unsupported);
return;
}
try {
msgEl.className = "msg";
buttonEl.disabled = true;
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
var publicKey = normalizeOptions(readPublicKeyOptions());
var credential = await navigator.credentials.get({ publicKey: publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
await postResult({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
});
sentSuccess = true;
showMessage("success", text.success);
} catch (error) {
buttonEl.disabled = false;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
}
}
})();
</script>
</body>
</html>
+552 -52
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'wouter';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell'; import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays'; import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
import AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
import AuthViews from '@/components/AuthViews'; import AuthViews from '@/components/AuthViews';
import NotFoundPage from '@/components/NotFoundPage'; import NotFoundPage from '@/components/NotFoundPage';
import PublicSendPage from '@/components/PublicSendPage'; import PublicSendPage from '@/components/PublicSendPage';
@@ -10,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot, clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
@@ -18,15 +20,22 @@ import {
loadProfileSnapshot, loadProfileSnapshot,
saveProfileSnapshot, saveProfileSnapshot,
revokeCurrentSession, revokeCurrentSession,
getTotpStatus, getTwoFactorProviderStatus,
getVaultRevisionDate,
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import {
encryptSessionUserKeyForAuthRequest,
isPendingAuthRequest,
listPendingAuthRequests,
respondToAuthRequest,
} from '@/lib/api/auth-requests';
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
import { getSends } from '@/lib/api/send'; import { getSendById, getSends } from '@/lib/api/send';
import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot, saveVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { import {
parseSignalRTextFrames, parseSignalRTextFrames,
@@ -49,6 +58,7 @@ import {
type PendingPasskeyPassword, type PendingPasskeyPassword,
type PendingTotp, type PendingTotp,
} from '@/lib/app-auth'; } from '@/lib/app-auth';
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions'; import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions'; import useBackupActions from '@/hooks/useBackupActions';
@@ -74,7 +84,7 @@ import {
createDemoMainRoutesProps, createDemoMainRoutesProps,
} from '@/lib/demo'; } from '@/lib/demo';
import type { AdminBackupSettings } from '@/lib/api/backup'; import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache'; import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
@@ -94,6 +104,8 @@ const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.fil
const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules'; const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const; const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
const APP_ROUTE_PATHS = [ const APP_ROUTE_PATHS = [
'/', '/',
@@ -102,7 +114,8 @@ const APP_ROUTE_PATHS = [
'/sends', '/sends',
'/admin', '/admin',
'/logs', '/logs',
'/security/devices', LEGACY_DEVICE_MANAGEMENT_ROUTE,
DEVICE_MANAGEMENT_ROUTE,
'/backup', '/backup',
'/settings', '/settings',
SETTINGS_ACCOUNT_ROUTE, SETTINGS_ACCOUNT_ROUTE,
@@ -124,10 +137,24 @@ function normalizeRoutePath(path: string): string {
} }
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
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_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
type ThemePreference = 'system' | 'light' | 'dark'; type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
@@ -201,6 +228,7 @@ export default function App() {
hint: null, hint: null,
}); });
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null); const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
@@ -213,6 +241,9 @@ export default function App() {
const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
@@ -237,7 +268,13 @@ export default function App() {
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -259,15 +296,16 @@ export default function App() {
}, [pushToast]); }, [pushToast]);
useEffect(() => { useEffect(() => {
const syncInviteFromUrl = () => { const syncUrlState = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl()); setInviteCodeFromUrl(readInviteCodeFromUrl());
setHashPathRaw(window.location.hash || '');
}; };
syncInviteFromUrl(); syncUrlState();
window.addEventListener('hashchange', syncInviteFromUrl); window.addEventListener('hashchange', syncUrlState);
window.addEventListener('popstate', syncInviteFromUrl); window.addEventListener('popstate', syncUrlState);
return () => { return () => {
window.removeEventListener('hashchange', syncInviteFromUrl); window.removeEventListener('hashchange', syncUrlState);
window.removeEventListener('popstate', syncInviteFromUrl); window.removeEventListener('popstate', syncUrlState);
}; };
}, []); }, []);
@@ -479,7 +517,15 @@ export default function App() {
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -493,7 +539,6 @@ export default function App() {
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault'); navigate('/vault');
} }
pushToast('success', successMessage);
void (async () => { void (async () => {
try { try {
const hydratedProfile = await login.profilePromise; const hydratedProfile = await login.profilePromise;
@@ -510,7 +555,7 @@ export default function App() {
if (IS_DEMO_MODE) { if (IS_DEMO_MODE) {
setPendingAuthAction('login'); setPendingAuthAction('login');
try { try {
await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success')); await finalizeLogin(createDemoCompletedLogin(loginValues.email));
} finally { } finally {
setPendingAuthAction(null); setPendingAuthAction(null);
} }
@@ -582,7 +627,7 @@ export default function App() {
try { try {
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail); const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
if (result.kind === 'success') { if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked')); await finalizeLogin(result.login);
return; return;
} }
if (result.kind === 'password') { if (result.kind === 'password') {
@@ -614,19 +659,38 @@ export default function App() {
} }
} }
function handleSelectTotpProvider(providerType: number) {
if (totpSubmitting) return;
setPendingTotp((current) => {
if (!current || current.providerType === providerType) return current;
const canUseProvider = current.availableProviders.includes(providerType);
if (!canUseProvider) return current;
return {
...current,
providerType,
providerData: current.providerDataByType[providerType],
};
});
setTotpCode('');
}
async function handleTotpVerify() { async function handleTotpVerify() {
if (totpSubmitting) return; if (totpSubmitting) return;
if (!pendingTotp) return; if (!pendingTotp) return;
if (!totpCode.trim()) { const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
pushToast('error', t('txt_please_input_totp_code')); if (!isPasskeyTwoFactor && !totpCode.trim()) {
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
return; return;
} }
setTotpSubmitting(true); setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const token = isPasskeyTwoFactor
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success')); ? await assertTwoFactorPasskey(pendingTotp.providerData)
: totpCode;
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
await finalizeLogin(login);
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
} finally { } finally {
setTotpSubmitting(false); setTotpSubmitting(false);
} }
@@ -764,7 +828,7 @@ export default function App() {
if (IS_DEMO_MODE) { if (IS_DEMO_MODE) {
setPendingAuthAction('unlock'); setPendingAuthAction('unlock');
try { try {
await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked')); await finalizeLogin(createDemoCompletedLogin(session.email));
} finally { } finally {
setPendingAuthAction(null); setPendingAuthAction(null);
} }
@@ -778,7 +842,7 @@ export default function App() {
try { try {
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
if (result.kind === 'success') { if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked')); await finalizeLogin(result.login);
return; return;
} }
if (result.kind === 'totp') { if (result.kind === 'totp') {
@@ -911,11 +975,14 @@ export default function App() {
confirm={null} confirm={null}
onCancelConfirm={() => {}} onCancelConfirm={() => {}}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}} onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false} totpSubmitting={false}
@@ -1041,9 +1108,9 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const totpStatusQuery = useQuery({ const twoFactorStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email], queryKey: ['two-factor-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch), queryFn: () => getTwoFactorProviderStatus(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -1060,6 +1127,74 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey,
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000,
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
setAuthRequestSubmittingId(authRequest.id);
try {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestSubmittingId(authRequest.id);
try {
await respondToAuthRequest(authedFetch, authRequest.id, {
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: false,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch();
} finally {
setAuthRequestSubmittingId(null);
}
}
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> { function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains); const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains); const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
@@ -1117,13 +1252,25 @@ export default function App() {
if (!isAdminProfile(profile)) return; if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
@@ -1269,6 +1416,193 @@ export default function App() {
silentRefreshVaultRef.current = refreshVaultSilently; silentRefreshVaultRef.current = refreshVaultSilently;
function normalizeVaultCoreSnapshot(snapshot?: Partial<VaultCoreSnapshot> | null): VaultCoreSnapshot {
return {
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
};
}
function upsertById<T extends { id: string }>(items: T[], nextItem: T): T[] {
const nextId = String(nextItem.id || '').trim();
if (!nextId) return items;
const index = items.findIndex((item) => String(item.id || '').trim() === nextId);
if (index < 0) return [...items, nextItem];
const next = items.slice();
next[index] = nextItem;
return next;
}
function removeById<T extends { id: string }>(items: T[], id: string): T[] {
const normalizedId = String(id || '').trim();
if (!normalizedId) return items;
return items.filter((item) => String(item.id || '').trim() !== normalizedId);
}
function revisionStampFromIso(value: unknown): number | null {
const stamp = new Date(String(value || '').trim()).getTime();
return Number.isFinite(stamp) && stamp > 0 ? stamp : null;
}
function patchVaultCoreSnapshot(
updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot,
options?: { revisionStamp?: number | null }
): void {
if (!vaultCacheKey) return;
let nextSnapshot: VaultCoreSnapshot | null = null;
queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => {
const base = normalizeVaultCoreSnapshot(previous || cachedVaultCore);
nextSnapshot = updater(base);
return nextSnapshot;
});
if (nextSnapshot) {
setCachedVaultCore(nextSnapshot);
void saveVaultCoreSyncSnapshot(vaultCacheKey, nextSnapshot, options?.revisionStamp ?? null);
}
}
async function refreshVaultCoreRevisionStamp(): Promise<void> {
if (!vaultCacheKey || !session?.accessToken) return;
try {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const currentSnapshot = normalizeVaultCoreSnapshot(
queryClient.getQueryData<VaultCoreSnapshot>(['vault-core', vaultCacheKey]) || cachedVaultCore
);
await saveVaultCoreSyncSnapshot(vaultCacheKey, currentSnapshot, revisionStamp);
} catch {
// A stale revision stamp only affects the next cache validation; the local resource patch remains valid.
}
}
function upsertEncryptedCipher(cipher: Cipher, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: upsertById(snapshot.ciphers, cipher),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(cipher.revisionDate) });
}
function deleteCipherLocally(cipherId: string, revisionStamp?: number | null): void {
const id = String(cipherId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: removeById(snapshot.ciphers, id),
}), { revisionStamp });
setDecryptedCiphers((current) => removeById(current, id));
}
function upsertEncryptedFolder(folder: VaultFolder, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: upsertById(snapshot.folders, folder),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(folder.revisionDate) });
}
function deleteFolderLocally(folderId: string, revisionStamp?: number | null): void {
const id = String(folderId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: removeById(snapshot.folders, id),
ciphers: snapshot.ciphers.map((cipher) => (
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
)),
}), { revisionStamp });
setDecryptedFolders((current) => removeById(current, id));
setDecryptedCiphers((current) => current.map((cipher) => (
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
)));
}
function upsertEncryptedSend(send: Send, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
sends: upsertById(snapshot.sends, send),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(send.revisionDate) });
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send));
}
function deleteSendLocally(sendId: string, revisionStamp?: number | null): void {
const id = String(sendId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
sends: removeById(snapshot.sends, id),
}), { revisionStamp });
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id));
setDecryptedSends((current) => removeById(current, id));
}
async function upsertCipherFromNotification(cipherId: string, revisionStamp?: number | null): Promise<void> {
const id = String(cipherId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getCipherById(authedFetch, id);
upsertEncryptedCipher(encrypted, revisionStamp);
const result = await decryptVaultCore({
folders: [],
ciphers: [encrypted],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
});
const decrypted = result.ciphers[0];
if (decrypted) setDecryptedCiphers((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteCipherLocally(id);
return;
}
console.warn('Failed to upsert cipher from notification:', error);
}
}
async function upsertFolderFromNotification(folderId: string, revisionStamp?: number | null): Promise<void> {
const id = String(folderId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getFolderById(authedFetch, id);
upsertEncryptedFolder(encrypted, revisionStamp);
const result = await decryptVaultCore({
folders: [encrypted],
ciphers: [],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
});
const decrypted = result.folders[0];
if (decrypted) setDecryptedFolders((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteFolderLocally(id);
return;
}
console.warn('Failed to upsert folder from notification:', error);
}
}
async function upsertSendFromNotification(sendId: string, revisionStamp?: number | null): Promise<void> {
const id = String(sendId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getSendById(authedFetch, id);
upsertEncryptedSend(encrypted, revisionStamp);
const sends = await decryptSends({
sends: [encrypted],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
origin: window.location.origin,
});
const decrypted = sends[0];
if (decrypted) setDecryptedSends((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteSendLocally(id);
return;
}
console.warn('Failed to upsert send from notification:', error);
}
}
useEffect(() => { useEffect(() => {
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
@@ -1345,7 +1679,18 @@ export default function App() {
const frames = parseSignalRTextFrames(event.data); const frames = parseSignalRTextFrames(event.data);
for (const frame of frames) { for (const frame of frames) {
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
const updateType = Number(frame.arguments?.[0]?.Type || 0); const message = frame.arguments?.[0] as Record<string, unknown> | undefined;
const updateType = Number(message?.Type || 0);
const contextId = String(message?.ContextId || '').trim();
const payload = message?.Payload;
const payloadRecord = payload && typeof payload === 'object' ? payload as Record<string, unknown> : null;
const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim();
const revisionStamp = revisionStampFromIso(
payloadRecord?.RevisionDate
|| payloadRecord?.revisionDate
|| message?.Date
|| message?.date
);
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
logoutNow(); logoutNow();
return; return;
@@ -1354,14 +1699,16 @@ export default function App() {
void refreshAuthorizedDevicesRef.current(); void refreshAuthorizedDevicesRef.current();
continue; continue;
} }
if (updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST || updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE) {
void refreshPendingAuthRequestsRef.current();
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload;
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
continue; continue;
} }
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue; if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS || updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) {
if (notificationRefreshTimerRef.current !== null) { if (notificationRefreshTimerRef.current !== null) {
window.clearTimeout(notificationRefreshTimerRef.current); window.clearTimeout(notificationRefreshTimerRef.current);
} }
@@ -1369,6 +1716,32 @@ export default function App() {
notificationRefreshTimerRef.current = null; notificationRefreshTimerRef.current = null;
void silentRefreshVaultRef.current(); void silentRefreshVaultRef.current();
}, 250); }, 250);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) {
void upsertCipherFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) {
deleteCipherLocally(resourceId, revisionStamp);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) {
void upsertFolderFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) {
deleteFolderLocally(resourceId, revisionStamp);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) {
void upsertSendFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) {
deleteSendLocally(resourceId, revisionStamp);
continue;
}
} }
}); });
@@ -1427,8 +1800,33 @@ export default function App() {
}, },
refetchSends: refetchSendsFromVaultCore, refetchSends: refetchSendsFromVaultCore,
onNotify: pushToast, onNotify: pushToast,
patchEncryptedCiphers: (updater) => {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: updater(snapshot.ciphers),
}));
},
patchEncryptedFolders: (updater) => {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: updater(snapshot.folders),
}));
},
patchEncryptedSends: (updater) => {
let nextSends: Send[] = [];
patchVaultCoreSnapshot((snapshot) => {
nextSends = updater(snapshot.sends);
return {
...snapshot,
sends: nextSends,
};
});
queryClient.setQueryData(sendsQueryKey, nextSends);
},
patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedCiphers: setDecryptedCiphers,
patchDecryptedFolders: setDecryptedFolders, patchDecryptedFolders: setDecryptedFolders,
patchDecryptedSends: setDecryptedSends,
refreshVaultRevisionStamp: refreshVaultCoreRevisionStamp,
}); });
const accountSecurityActions = useAccountSecurityActions({ const accountSecurityActions = useAccountSecurityActions({
authedFetch, authedFetch,
@@ -1444,7 +1842,7 @@ export default function App() {
onNotify: pushToast, onNotify: pushToast,
onProfileUpdated: setProfile, onProfileUpdated: setProfile,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch, refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
@@ -1459,8 +1857,12 @@ export default function App() {
if (!vaultInitialDecryptDone) return; if (!vaultInitialDecryptDone) return;
await authorizedDevicesQuery.refetch(); await authorizedDevicesQuery.refetch();
}; };
refreshPendingAuthRequestsRef.current = async () => {
if (!vaultInitialDecryptDone || !(profile?.email || session?.email)) return;
setAuthRequestDialogDismissedId(null);
await pendingAuthRequestsQuery.refetch();
};
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, ''); const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -1477,7 +1879,7 @@ export default function App() {
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute; const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation)); const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation); const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const showSidebarToggle = mobileLayout && location === '/sends';
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
const demoDomainRules = useMemo<DomainRules>(() => ({ const demoDomainRules = useMemo<DomainRules>(() => ({
equivalentDomains: [ equivalentDomains: [
@@ -1509,7 +1911,7 @@ export default function App() {
if (location === '/sends') return t('nav_sends'); if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel'); if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center'); if (location === '/logs') return t('nav_log_center');
if (location === '/security/devices') return t('nav_device_management'); if (location === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) return t('nav_device_management');
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules'); if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
if (location === '/backup') return t('nav_backup_strategy'); if (location === '/backup') return t('nav_backup_strategy');
if (isImportRoute) return t('nav_import_export'); if (isImportRoute) return t('nav_import_export');
@@ -1518,6 +1920,16 @@ export default function App() {
return t('nav_my_vault'); return t('nav_my_vault');
})(); })();
useEffect(() => {
if (phase !== 'app') return;
if (!hashPath.startsWith('/')) return;
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
}
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
}, [phase, hashPath, normalizedHashPath, location, navigate]);
useEffect(() => { useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]); }, [phase, location, isPublicSendRoute, navigate]);
@@ -1552,6 +1964,7 @@ export default function App() {
session, session,
mobileLayout, mobileLayout,
mobileSidebarToggleKey, mobileSidebarToggleKey,
themePreference,
importRoute: IMPORT_ROUTE, importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE, settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1566,10 +1979,13 @@ export default function App() {
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
lockTimeoutMinutes, lockTimeoutMinutes,
sessionTimeoutAction, sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null, domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -1578,6 +1994,7 @@ export default function App() {
onNavigate: navigate, onNavigate: navigate,
onLogout: handleLogout, onLogout: handleLogout,
onNotify: pushToast, onNotify: pushToast,
onThemePreferenceChange: setThemePreference,
onImport: vaultSendActions.importVault, onImport: vaultSendActions.importVault,
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw, onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
onExport: vaultSendActions.exportVault, onExport: vaultSendActions.exportVault,
@@ -1612,11 +2029,20 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await twoFactorStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
onDisableYubiKey: accountSecurityActions.disableYubiKey,
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey, onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey,
@@ -1624,6 +2050,17 @@ export default function App() {
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
onRefreshTwoFactorStatus: async () => {
await twoFactorStatusQuery.refetch();
},
pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
@@ -1635,34 +2072,70 @@ export default function App() {
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently, onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin, onRefreshAdmin: adminActions.refreshAdmin,
onCreateInvite: adminActions.createInvite, onCreateInvite: adminActions.createInvite,
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
onDeleteAllInvites: adminActions.deleteAllInvites, onDeleteAllInvites: adminActions.deleteAllInvites,
onToggleUserStatus: adminActions.toggleUserStatus, onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser, onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite, onDeleteInvite: adminActions.deleteInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
onImportBackup: backupActions.importBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({ onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
staleTime: 30_000, staleTime: 30_000,
}), }),
onSaveBackupSettings: backupActions.saveSettings, onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
onRunRemoteBackup: backupActions.runRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups, onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
onInspectRemoteBackup: backupActions.inspectRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
onRestoreRemoteBackup: backupActions.restoreRemoteBackup, },
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.inspectRemoteBackup(hash, destinationId, path);
},
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path);
},
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
}; };
const effectiveMainRoutesProps = IS_DEMO_MODE const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -1780,11 +2253,14 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp} pendingTotpOpen={!!pendingTotp}
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
totpCode={totpCode} totpCode={totpCode}
rememberDevice={rememberDevice} rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode} onTotpCodeChange={setTotpCode}
onRememberDeviceChange={setRememberDevice} onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()} onConfirmTotp={() => void handleTotpVerify()}
onSelectTotpProvider={handleSelectTotpProvider}
onCancelTotp={() => { onCancelTotp={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
@@ -1839,11 +2315,14 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}} onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false} totpSubmitting={false}
@@ -1868,6 +2347,27 @@ export default function App() {
}} }}
disableTotpSubmitting={disableTotpSubmitting} disableTotpSubmitting={disableTotpSubmitting}
/> />
<AuthRequestApprovalDialog
open={authRequestDialogOpen}
authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId}
onApprove={() => {
if (!authRequestDialogRequest) return;
void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onDeny={() => {
if (!authRequestDialogRequest) return;
void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/>
</> </>
); );
} }
+8 -6
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,4 +1,4 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, 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';
@@ -47,15 +47,20 @@ 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 isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules'; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute; const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs'; const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode); const [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);
@@ -171,13 +176,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))} {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))} {renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))} {renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))} {renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))} {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'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))} {isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
</> </>
); );
@@ -202,6 +206,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
@@ -222,7 +227,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
</> </>
)} )}
</> </>
+86 -6
View File
@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost'; import ToastHost from '@/components/ToastHost';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -21,11 +22,14 @@ interface AppGlobalOverlaysProps {
confirm: AppConfirmState | null; confirm: AppConfirmState | null;
onCancelConfirm: () => void; onCancelConfirm: () => void;
pendingTotpOpen: boolean; pendingTotpOpen: boolean;
pendingTotpProviderType?: number;
pendingTotpAvailableProviders?: number[];
totpCode: string; totpCode: string;
rememberDevice: boolean; rememberDevice: boolean;
onTotpCodeChange: (value: string) => void; onTotpCodeChange: (value: string) => void;
onRememberDeviceChange: (checked: boolean) => void; onRememberDeviceChange: (checked: boolean) => void;
onConfirmTotp: () => void; onConfirmTotp: () => void;
onSelectTotpProvider: (providerType: number) => void;
onCancelTotp: () => void; onCancelTotp: () => void;
onUseRecoveryCode: () => void; onUseRecoveryCode: () => void;
totpSubmitting: boolean; totpSubmitting: boolean;
@@ -37,7 +41,40 @@ interface AppGlobalOverlaysProps {
disableTotpSubmitting: boolean; disableTotpSubmitting: boolean;
} }
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_ORDER = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
const available = new Set(providerTypes || []);
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
}
function twoFactorProviderLabel(providerType: number): string {
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
return t('txt_authenticator_app');
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders]
);
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
useEffect(() => {
setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -55,10 +92,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
title={t('txt_two_step_verification')} title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
message={t('txt_password_is_already_verified')} <span className="dialog-title-stack">
<span>{t('txt_two_step_verification')}</span>
<span>{t('txt_passkey')}</span>
</span>
) : t('txt_two_step_verification')}
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
confirmText={t('txt_verify')} confirmText={t('txt_verify')}
cancelText={t('txt_cancel')} hideCancel
closeButton
showIcon={false} showIcon={false}
confirmDisabled={props.totpSubmitting} confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting} cancelDisabled={props.totpSubmitting}
@@ -67,16 +110,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
afterActions={( afterActions={(
<div className="dialog-extra"> <div className="dialog-extra">
<div className="dialog-divider" /> <div className="dialog-divider" />
{alternateProviders.length > 0 && (
<div className="two-factor-method-switcher">
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.totpSubmitting}
aria-expanded={methodChooserOpen}
onClick={() => setMethodChooserOpen((open) => !open)}
>
{t('txt_select_another_verification_method')}
</button>
{methodChooserOpen && (
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
{alternateProviders.map((providerType) => (
<button
key={providerType}
type="button"
className="btn btn-secondary two-factor-method-option"
disabled={props.totpSubmitting}
onClick={() => {
setMethodChooserOpen(false);
props.onSelectTotpProvider(providerType);
}}
>
{twoFactorProviderLabel(providerType)}
</button>
))}
</div>
)}
</div>
)}
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}> <button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')} {t('txt_use_recovery_code')}
</button> </button>
</div> </div>
)} )}
> >
{isWebAuthn ? (
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
) : (
<label className="field"> <label className="field">
<span>{t('txt_totp_code')}</span> <span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
)}
<label className="check-line check-line-compact"> <label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} /> <input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span> <span>{t('txt_trust_this_device_for_30_days')}</span>
@@ -88,7 +167,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
title={t('txt_disable_totp')} title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')} message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')} confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')} hideCancel
closeButton
danger danger
showIcon={false} showIcon={false}
confirmDisabled={props.disableTotpSubmitting} confirmDisabled={props.disableTotpSubmitting}
+91 -29
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin'; import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -39,6 +39,7 @@ export interface AppMainRoutesProps {
session: SessionState | null; session: SessionState | null;
mobileLayout: boolean; mobileLayout: boolean;
mobileSidebarToggleKey: number; mobileSidebarToggleKey: number;
themePreference: 'system' | 'light' | 'dark';
importRoute: string; importRoute: string;
settingsHomeRoute: string; settingsHomeRoute: string;
settingsAccountRoute: string; settingsAccountRoute: string;
@@ -54,9 +55,12 @@ export interface AppMainRoutesProps {
adminLoading: boolean; adminLoading: boolean;
adminError: string; adminError: string;
totpEnabled: boolean; totpEnabled: boolean;
yubikeyEnabled: boolean;
passkey2faEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
currentDeviceIdentifier: string;
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
authorizedDevicesError: string; authorizedDevicesError: string;
domainRules: DomainRules | null; domainRules: DomainRules | null;
@@ -65,6 +69,7 @@ export interface AppMainRoutesProps {
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
onLogout: () => void; onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
onImport: ( onImport: (
payload: CiphersImportPayload, payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
@@ -107,8 +112,17 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -116,6 +130,13 @@ export interface AppMainRoutesProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onRefreshTwoFactorStatus: () => Promise<void>;
pendingAuthRequests: AuthRequest[];
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>;
@@ -125,34 +146,37 @@ export interface AppMainRoutesProps {
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void; onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void; onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onRefreshAdmin: () => void; onRefreshAdmin: () => void;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>; onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onDeleteInvite: (code: string) => Promise<void>;
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>; onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadAuditLogSettings: () => Promise<AuditLogSettings>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>; onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
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 />}>
@@ -256,12 +280,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
yubikeyEnabled={props.yubikeyEnabled}
passkey2faEnabled={props.passkey2faEnabled}
themePreference={props.themePreference}
lockTimeoutMinutes={props.lockTimeoutMinutes} lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction} sessionTimeoutAction={props.sessionTimeoutAction}
onThemePreferenceChange={props.onThemePreferenceChange}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetYubiKeySettings={props.onGetYubiKeySettings}
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
onDisableYubiKey={props.onDisableYubiKey}
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
@@ -269,6 +307,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
@@ -281,13 +320,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Route> </Route>
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link"> <Link href={props.settingsAccountRoute} className="mobile-settings-link">
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<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>
@@ -295,22 +336,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Globe2 size={18} /> <Globe2 size={18} />
<span>{t('nav_domain_rules')}</span> <span>{t('nav_domain_rules')}</span>
</Link> </Link>
</div>
</div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link"> <Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} /> <ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span> <span>{t('nav_import_export')}</span>
</Link> </Link>
{isAdmin && (
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
)}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
@@ -318,6 +352,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
)} )}
</div> </div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
</div>
</div>
)}
<div className="settings-home-spacer" />
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}> <button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
<LogOut size={14} className="btn-icon" /> <LogOut size={14} className="btn-icon" />
{t('txt_sign_out')} {t('txt_sign_out')}
@@ -327,7 +378,8 @@ 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) => (
<Route key={path} path={path}>
<div className="stack"> <div className="stack">
{props.mobileLayout && ( {props.mobileLayout && (
<div className="mobile-settings-subhead"> <div className="mobile-settings-subhead">
@@ -340,19 +392,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage <SecurityDevicesPage
devices={props.authorizedDevices} devices={props.authorizedDevices}
currentDeviceIdentifier={props.currentDeviceIdentifier}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError} error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onRenameDevice={props.onRenameAuthorizedDevice} onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently} onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices} onRemoveAll={props.onRemoveAllDevices}
/> />
</Suspense> </Suspense>
</div> </div>
</Route> </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 && (
@@ -394,10 +455,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
error={props.adminError} error={props.adminError}
onRefresh={props.onRefreshAdmin} onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite} onCreateInvite={props.onCreateInvite}
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
onDeleteAllInvites={props.onDeleteAllInvites} onDeleteAllInvites={props.onDeleteAllInvites}
onToggleUserStatus={props.onToggleUserStatus} onToggleUserStatus={props.onToggleUserStatus}
onDeleteUser={props.onDeleteUser} onDeleteUser={props.onDeleteUser}
onRevokeInvite={props.onRevokeInvite} onDeleteInvite={props.onDeleteInvite}
/> />
</Suspense> </Suspense>
</div> </div>
@@ -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>
);
}
+261 -68
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: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
@@ -53,6 +53,16 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'deleteRemote'; destinationId: string; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -184,7 +194,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState(''); const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState(''); const [, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null); const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0); const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
@@ -193,6 +203,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordError, setBackupPasswordError] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +223,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +240,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'deleteRemote'
? t('txt_delete')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
setPendingBackupVerification(request);
setBackupPasswordValue('');
setBackupPasswordError('');
}
function showActionError(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : fallback;
setLocalError(message);
if (backupPasswordSubmitting || pendingBackupVerification) {
setBackupPasswordError(message);
}
props.onNotify('error', message);
return message;
}
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -448,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return verifyBackupFileIntegrity(bytes, file.name || ''); return verifyBackupFileIntegrity(bytes, file.name || '');
} }
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> { async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path); const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
return { return {
source: 'remote', source: 'remote',
path, path,
@@ -478,10 +522,15 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(nextSettings); const saved = await props.onSaveSettings(masterPassword, payload);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved); setSavedSettings(saved);
@@ -497,27 +546,32 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false); setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted')); props.onNotify('success', t('txt_backup_destination_deleted'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); showActionError(error, t('txt_backup_settings_save_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
openBackupPasswordPrompt({ action: 'export' });
}
async function executeExport(masterPassword: string): Promise<boolean> {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); showActionError(error, t('txt_backup_export_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -535,6 +589,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
props.onNotify('error', message); props.onNotify('error', message);
return; return;
} }
openBackupPasswordPrompt({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
): Promise<boolean> {
if (importing) return false;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
setBackupPasswordError(message);
props.onNotify('error', message);
return false;
}
setLocalError(''); setLocalError('');
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false); setConfirmReplaceOpen(false);
@@ -547,8 +623,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -556,29 +632,34 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false); setConfirmReplaceOpen(false);
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
return true;
} catch (error) { } catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) { if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress(); clearRestoreProgress();
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true); setConfirmReplaceOpen(true);
return; return true;
} }
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed'); showActionError(error, t('txt_backup_restore_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setImporting(false); setImporting(false);
} }
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
openBackupPasswordPrompt({ action: 'saveSettings' });
}
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -592,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved')); props.onNotify('success', t('txt_backup_settings_saved'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); showActionError(error, t('txt_backup_settings_save_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
@@ -613,7 +694,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination) return; if (!selectedDestination || runningRemoteBackup) return;
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
}
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
if (!selectedDestination) return false;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
try { try {
@@ -621,33 +707,37 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName })); props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); showActionError(error, t('txt_backup_remote_run_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setRunningRemoteBackup(false); setRunningRemoteBackup(false);
} }
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
if (!savedSelectedDestination) return; openBackupPasswordPrompt({ action: 'downloadRemote', path });
}
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
if (!savedSelectedDestination) return false;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); showActionError(error, t('txt_backup_remote_download_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setDownloadingRemotePath(''); setDownloadingRemotePath('');
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
@@ -657,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleDeleteRemote(path: string) { async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return; if (deletingRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
setConfirmRemoteDeleteOpen(false);
}
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
if (deletingRemotePath) return false;
setDeletingRemotePath(path); setDeletingRemotePath(path);
setLocalError(''); setLocalError('');
try { try {
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path); await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
setConfirmRemoteDeleteOpen(false); setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath(''); setPendingRemoteDeletePath('');
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
props.onNotify('success', t('txt_backup_remote_delete_success')); props.onNotify('success', t('txt_backup_remote_delete_success'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed'); showActionError(error, t('txt_backup_remote_delete_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setDeletingRemotePath(''); setDeletingRemotePath('');
} }
@@ -704,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setLocalError(''); setLocalError('');
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
try { await runRemoteRestore(path, false);
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
} }
async function runRemoteRestore( async function runRemoteRestore(
@@ -727,41 +811,114 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
openBackupPasswordPrompt({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
): Promise<boolean> {
if (restoringRemotePath) return false;
if (!savedSelectedDestination) return false;
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false); setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path); setRestoringRemotePath(path);
setLocalError(''); setLocalError('');
try { try {
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path); const integrity = knownIntegrity
? { result: knownIntegrity }
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
if (!allowChecksumMismatch && !integrity.result.matches) {
setPendingRestoreIntegrity(
'source' in integrity
? integrity
: {
source: 'remote',
path,
fileName: path.split('/').pop() || path,
result: integrity.result,
}
);
setConfirmIntegrityWarningOpen(true);
return true;
}
startRestoreProgress('backup-restore', path.split('/').pop() || path, { startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote', source: 'remote',
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
return true;
} catch (error) { } catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) { if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path); setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true); setConfirmRemoteReplaceOpen(true);
clearRestoreProgress(); clearRestoreProgress();
return; return true;
} }
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); showActionError(error, t('txt_backup_remote_restore_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setRestoringRemotePath(''); setRestoringRemotePath('');
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
setBackupPasswordError(t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setBackupPasswordError('');
let succeeded = false;
try {
if (request.action === 'export') {
succeeded = await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
succeeded = await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
succeeded = await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
succeeded = await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'deleteRemote') {
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
} else if (request.action === 'restoreRemote') {
succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
if (succeeded) {
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -848,7 +1005,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}} }}
/> />
{localError ? <div className="local-error">{localError}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal(( {restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite"> <div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal"> <section className="restore-progress-card restore-progress-modal">
@@ -893,6 +1049,43 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
id="backup-master-password"
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
aria-invalid={!!backupPasswordError}
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
onInput={(event) => {
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
if (backupPasswordError) setBackupPasswordError('');
}}
/>
{backupPasswordError ? (
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
) : null}
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
+32 -7
View File
@@ -1,19 +1,21 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact'; import { TriangleAlert, X } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean; open: boolean;
title: string; title: ComponentChildren;
message: string; message?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'warning';
showIcon?: boolean; showIcon?: boolean;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
danger?: boolean; danger?: boolean;
hideCancel?: boolean; hideCancel?: boolean;
hideConfirm?: boolean;
closeButton?: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
cancelDisabled?: boolean; cancelDisabled?: boolean;
onConfirm: () => void; onConfirm: () => void;
@@ -83,14 +85,18 @@ 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 lastTitleRef = useRef<ComponentChildren>(props.title);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`; const messageId = `${dialogId}-message`;
const hasMessage = !!props.message;
const canDismiss = !props.cancelDisabled && !closing; const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => { useEffect(() => {
if (props.open) { if (props.open) {
lastTitleRef.current = props.title;
setPresent(true); setPresent(true);
setClosing(false); setClosing(false);
return; return;
@@ -176,8 +182,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();
}} }}
> >
@@ -187,7 +196,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={messageId} aria-describedby={hasMessage ? messageId : undefined}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleDialogKeyDown} onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
@@ -207,9 +216,24 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div> </div>
</> </>
) : null} ) : null}
<h3 id={titleId} className="dialog-title">{props.title}</h3> {props.closeButton && (
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> <button
type="button"
className="dialog-close-btn"
aria-label={t('txt_close')}
disabled={props.cancelDisabled}
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
}}
>
<X size={18} />
</button>
)}
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children} {props.children}
{!props.hideConfirm && (
<button <button
type="submit" type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
@@ -218,6 +242,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
> >
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
)}
{!props.hideCancel && ( {!props.hideCancel && (
<button <button
type="button" type="button"
+133 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js'; import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { strFromU8, unzipSync } from 'fflate'; import { strFromU8, unzipSync, type UnzipFileInfo } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog'; import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'keepassx_csv', 'keepassx_csv',
]; ];
const MAX_IMPORT_ZIP_BYTES = 256 * 1024 * 1024;
const MAX_IMPORT_ZIP_ENTRY_COUNT = 10_000;
const MAX_IMPORT_TEXT_ENTRY_BYTES = 32 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_BYTES = 100 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_TOTAL_BYTES = 512 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
@@ -171,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
} }
function formatMiB(bytes: number): string {
return String(Math.floor(bytes / (1024 * 1024)));
}
function zipEntryName(rawName: unknown): string {
return String(rawName || '').trim().replace(/\\/g, '/');
}
function assertSafeZipEntryName(name: string): void {
if (!name || name.includes('\0') || name.startsWith('/') || name.includes('//')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
const parts = name.split('/');
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
}
function assertImportZipSize(bytes: number): void {
if (bytes > MAX_IMPORT_ZIP_BYTES) {
throw new Error(t('txt_import_zip_too_large', { size: formatMiB(MAX_IMPORT_ZIP_BYTES) }));
}
}
function assertImportTextFileSize(bytes: number): void {
if (bytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_file_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
}
function assertImportEntrySize(size: number, maxBytes: number): void {
if (size > maxBytes) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(maxBytes) }));
}
}
function isImportTextZipCandidate(source: ImportSourceId, name: string): boolean {
const lower = name.toLowerCase();
if (source === 'onepassword_1pux') {
return lower.endsWith('/export.data') || lower === 'export.data' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
return lower.endsWith('/protonpass.json') || lower === 'protonpass.json' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
function createImportTextZipFilter(source: ImportSourceId): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalTextBytes = 0;
return (entry: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
const name = zipEntryName(entry.name);
assertSafeZipEntryName(name);
if (!isImportTextZipCandidate(source, name)) return false;
const originalSize = Number(entry.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
assertImportEntrySize(originalSize, MAX_IMPORT_TEXT_ENTRY_BYTES);
totalTextBytes += originalSize;
if (totalTextBytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
return true;
};
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string { function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes); assertImportZipSize(bytes.byteLength);
const unzippedRaw = unzipSync(bytes, { filter: createImportTextZipFilter(source) });
const unzipped: Record<string, Uint8Array> = {};
for (const [rawName, entryBytes] of Object.entries(unzippedRaw)) {
const name = zipEntryName(rawName);
assertSafeZipEntryName(name);
assertImportEntrySize(entryBytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
unzipped[name] = entryBytes;
}
const fileNames = Object.keys(unzipped); const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive')); if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
@@ -189,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
async function readImportText(file: File, source: ImportSourceId): Promise<string> { async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') { if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
assertImportTextFileSize(file.size);
return file.text(); return file.text();
} }
assertImportZipSize(file.size);
const bytes = new Uint8Array(await file.arrayBuffer()); const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source); if (isZipPayload(bytes)) return readZipText(bytes, source);
assertImportTextFileSize(bytes.byteLength);
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
return message.includes('password') || message.includes('encrypted'); return message.includes('password') || message.includes('encrypted');
} }
function bitwardenZipAttachmentMatch(name: string): RegExpMatchArray | null {
return name.match(/^attachments\/([^/]+)\/(.+)$/i);
}
function zipJsEntrySize(entry: unknown): number | null {
const size = Number((entry as { uncompressedSize?: unknown })?.uncompressedSize);
return Number.isFinite(size) && size >= 0 ? size : null;
}
function validateBitwardenZipEntries(entries: Awaited<ReturnType<ZipReader<unknown>['getEntries']>>): void {
if (entries.length > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
let totalAttachmentBytes = 0;
for (const entry of entries) {
if (entry.directory) continue;
const name = zipEntryName(entry.filename);
assertSafeZipEntryName(name);
const lower = name.toLowerCase();
const size = zipJsEntrySize(entry);
if (lower === 'data.json' && size != null) {
assertImportEntrySize(size, MAX_IMPORT_TEXT_ENTRY_BYTES);
} else if (bitwardenZipAttachmentMatch(name) && size != null) {
assertImportEntrySize(size, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += size;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
}
}
}
async function readBitwardenZipPayload( async function readBitwardenZipPayload(
file: File, file: File,
passwordRaw: string passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> { ): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim(); const password = String(passwordRaw || '').trim();
assertImportZipSize(file.size);
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false }); const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try { try {
const entries = await reader.getEntries(); const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive')); if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
validateBitwardenZipEntries(entries);
let jsonText = ''; let jsonText = '';
let totalAttachmentBytes = 0;
const attachments: ImportAttachmentFile[] = []; const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined; const options = password ? { password } : undefined;
for (const entry of entries) { for (const entry of entries) {
if (entry.directory) continue; if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/'); const name = zipEntryName(entry.filename);
if (!name) continue; if (!name) continue;
assertSafeZipEntryName(name);
const bytes = await entry.getData(new Uint8ArrayWriter(), options); const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase(); const lower = name.toLowerCase();
if (lower === 'data.json') { if (lower === 'data.json') {
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
jsonText = new TextDecoder().decode(bytes); jsonText = new TextDecoder().decode(bytes);
continue; continue;
} }
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i); const attachmentMatch = bitwardenZipAttachmentMatch(name);
if (!attachmentMatch) continue; if (!attachmentMatch) continue;
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += bytes.byteLength;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null; const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin'; const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({ attachments.push({
+1 -3
View File
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface JwtWarningPageProps { interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short'; reason: 'missing' | 'too_short';
minLength: number; minLength: number;
} }
@@ -21,8 +21,6 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const title = const title =
props.reason === 'missing' props.reason === 'missing'
? t('txt_jwt_title_missing') ? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short'); : t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
+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>
);
}
+11 -2
View File
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason); return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
} }
function formatTargetType(type: string): string {
return translatedOrHumanized(keyFor('txt_log_target_type_', type), type);
}
function formatTime(value: string): string { function formatTime(value: string): string {
const date = new Date(value); const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
@@ -148,11 +152,16 @@ function formatMetaValueForKey(key: string, value: unknown): string {
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value); return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
} }
if (key === 'type' && typeof value === 'string') { if (key === 'type' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value); return formatTargetType(value);
} }
return formatMetaValue(value); return formatMetaValue(value);
} }
function formatLogTarget(log: AuditLogEntry, metadata: Record<string, unknown>): string {
const targetEmail = typeof metadata.targetEmail === 'string' ? metadata.targetEmail : '';
return log.targetUserEmail || targetEmail || log.targetId || (log.targetType ? formatTargetType(log.targetType) : t('txt_dash'));
}
function iconForCategory(category: AuditLogCategory) { function iconForCategory(category: AuditLogCategory) {
if (category === 'auth') return <ShieldAlert size={16} />; if (category === 'auth') return <ShieldAlert size={16} />;
if (category === 'security') return <UserRound size={16} />; if (category === 'security') return <UserRound size={16} />;
@@ -550,7 +559,7 @@ export default function LogCenterPage(props: LogCenterPageProps) {
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div> <div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div> <div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div> <div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div> <div><span>{t('txt_target')}</span><strong>{formatLogTarget(selectedLog, selectedMetadata)}</strong></div>
</div> </div>
<div className="log-detail-json"> <div className="log-detail-json">
<h4>{t('txt_metadata')}</h4> <h4>{t('txt_metadata')}</h4>
+1 -6
View File
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
const Icon = status === 'online' ? Wifi : WifiOff; const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => { useEffect(() => {
let cancelled = false;
let timer = 0; let timer = 0;
const checkService = async () => { const checkService = async () => {
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return; return;
} }
const reachable = await probeNodeWardenService(); await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
}; };
const scheduleNextCheck = () => { const scheduleNextCheck = () => {
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
cancelled = true;
unsubscribe(); unsubscribe();
window.clearTimeout(timer); window.clearTimeout(timer);
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);

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