Compare commits

...
248 Commits
Author SHA1 Message Date
dependabot[bot]andGitHub 87273a0e81 chore(deps-dev): bump @cloudflare/workers-types
Bumps [@cloudflare/workers-types](https://github.com/cloudflare/workerd) from 4.20260630.1 to 5.20260712.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)

---
updated-dependencies:
- dependency-name: "@cloudflare/workers-types"
  dependency-version: 5.20260712.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-12 21:05:26 +00:00
shuaiplus b731a014f1 fix(auth): prevent unexpected session logout 2026-07-13 02:11:12 +08:00
shuaiplus e25ec159bb feat: enhance navigation layout and improve mobile settings UI 2026-07-12 23:16:01 +08:00
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
shuaiplus fb376797d2 feat: update PasswordGeneratorPage styles and improve layout responsiveness 2026-07-12 01:59:15 +08:00
shuaiplus 99b50275a6 feat: add Password Security feature with scanning and reporting capabilities 2026-07-12 01:50:21 +08:00
shuaiplus dfc98008cb Add password generator feature and update localization files 2026-07-11 18:49:26 +08:00
shuaiplus b472121f43 feat: add contributors section and star history chart to README files 2026-07-11 17:25:53 +08:00
DomainmasteriandGitHub 9caa064488 Add German (de), French (fr), Italian (it), and Swedish (sv) initial translations (#303) 2026-07-11 17:06:41 +08:00
shuaiplus aae614a079 feat: add offline mode notice and related styles to enhance user experience during offline access 2026-07-10 22:24:06 +08:00
shuaiplus 0e46cd371f Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-10 14:24:15 +08:00
shuaiplus db31792cef feat: add fill-assist, assetlinks check, and web-bootstrap to worker-handled paths 2026-07-10 14:23:45 +08:00
shuaiplus 8c65cb2e80 feat: update FIDO2 origins and enable direct unlock for account passkeys 2026-07-10 14:22:24 +08:00
shuaiplus 14dff8ee6a feat: disable new-device verification and update related logic across services 2026-07-10 13:00:32 +08:00
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
shuaiplus 3035a77579 chore: update version to 1.6.0 in package.json and app-version.ts 2026-06-10 17:05:32 +08:00
shuaiplus 28333f0e9b feat: update README to enhance PWA and Passkey features descriptions 2026-06-10 16:51:07 +08:00
shuaiplus 91320a4eba fix: persist offline unlock record during passkey PRF login
- Add fallbackKdfIterations parameter to completeLoginWithVaultKeys
- Save offline unlock record (email, profile, profileKey, kdfIterations)
  when completing vault-key-based login, ensuring offline unlock works
  after passkey (PRF) authentication
- Pass through fallbackIterations from performPasskeyLogin caller
- Add .reasonix/ to .gitignore
2026-06-10 13:44:43 +08:00
shuaiplus 19b96a7aca feat: add passkey unlock functionality and improve related error handling 2026-06-10 12:10:11 +08:00
shuaiplus 18e0396c0a feat: enhance account passkey functionality and improve error handling 2026-06-10 12:09:25 +08:00
shuaiplus 18d3490c4f feat: implement account passkey functionality
- Added functions for managing account passkeys including creation, listing, updating, and deletion.
- Introduced login methods using account passkeys with options for direct unlock and login-only modes.
- Enhanced error handling and response parsing for passkey-related API calls.
- Updated UI styles for account passkey management components.
- Added new translations for account passkey features in multiple languages.
- Modified network status handling to improve service reachability checks.
2026-06-10 00:53:41 +08:00
shuaiplus 615caf5946 feat: improve offline PWA resilience 2026-06-09 14:09:46 +08:00
rootphantomerandshuaiplus 1a10df4a18 fix: preserve cipher edit time during auto repair 2026-06-09 12:14:11 +08:00
shuaiplus d4749d3f82 feat: add PWA offline unlock support 2026-06-09 12:09:44 +08:00
shuaiplus 5ed7c949c1 feat: add remote backup restore and attachment download functionality 2026-06-07 21:06:34 +08:00
shuaiplus af70cab766 feat: implement BackupTransferRunner for managing backup processes and enhance backup handling 2026-06-07 20:43:43 +08:00
shuaiplus bfea5d0a1c fix: add support for KeePass CSV import format and enhance import parsing logic 2026-06-07 19:18:17 +08:00
shuaiplus cda654e1c3 fix: enhance cipher login URI handling and import format support 2026-06-06 22:43:16 +08:00
shuaiplus 1ee7b0f31b Fix initial i18n render crash on auth pages
Initialize locale messages before the first app render so the auth page does not diff from the fallback language into the detected locale during startup.

Mark the app root as non-translatable and keep the document language synchronized with the active locale to reduce browser translation DOM mutations.
2026-06-06 19:30:35 +08:00
shuaiplus 2d2cbea530 fix: add .tmp-bitwarden-clients/ to .gitignore 2026-05-31 21:23:53 +08:00
shuaiplus 4f5d992f10 fix: enhance cipher handling with repairable URI support and sync improvements 2026-05-31 19:53:42 +08:00
52assertandshuaiplus 667afa305b fix(deploy): make KV deploy idempotent
Adapted from #233 with deploy build kept in wrangler config.
2026-05-31 01:20:14 +08:00
shuaiplus 85bd2fa4bf fix: streamline deployment commands in configuration files 2026-05-31 01:15:00 +08:00
shuaiplus fd9707c396 fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling 2026-05-31 01:03:32 +08:00
shuaiplus 192071e4a7 fix: enhance cipher key handling and compatibility for secure notes 2026-05-30 02:43:09 +08:00
shuaiplus fcf7c80daa fix: adjust input padding for improved layout in forms and responsive styles 2026-05-30 02:34:45 +08:00
shuaiplus ed9251c014 fix: enhance compatibility for cipher login normalization and uri handling 2026-05-30 02:26:36 +08:00
shuaiplus a75955ca6d fix: update password verification to support legacy client hashes 2026-05-23 23:07:10 +08:00
shuaiplus 03f7fbf601 fix: repair mixed cipher key encryption handling 2026-05-23 12:43:44 +08:00
shuaiplus a63336764f fix: improve lock timeout retrieval by handling null and empty values 2026-05-23 03:19:49 +08:00
shuaiplus f56d7f01ca fix: add content length validation and timeout handling for icon fetching 2026-05-23 03:17:24 +08:00
shuaiplus 8ff60aed24 fix: remove unused change password handling functions from public route 2026-05-23 03:08:21 +08:00
shuaiplus 749de4e2e1 fix: update server hash prefix handling for password hashing and verification 2026-05-23 03:00:58 +08:00
shuaiplus ea9e238aa7 fix: remove checks for portable admins in backup settings saving and normalization 2026-05-23 02:53:03 +08:00
shuaiplus 22d267f5bc fix: remove unused saveRefreshTokenRecord parameter from getRefreshTokenRecord 2026-05-23 02:42:08 +08:00
shuaiplus 18eefd1174 fix: simplify login identifier construction in two-factor recovery and token handling 2026-05-23 02:22:04 +08:00
shuaiplus d468745841 fix: restore ip-scoped password login lockout 2026-05-23 02:12:40 +08:00
shuaiplus 970621c459 fix: remove optional TOTP_SECRET from environment bindings 2026-05-23 02:07:59 +08:00
shuaiplus 385a873e65 fix: improve device validation logic in refresh token handling 2026-05-23 02:00:41 +08:00
shuaiplus 56185ecb69 fix: strip plaintext login helpers from cipher payload 2026-05-23 01:49:34 +08:00
shuaiplus 04ebfc7021 feat: refactor cipher login data type for improved clarity 2026-05-18 02:13:01 +08:00
shuaiplus c50247b8fe feat: add URI checksum repair functionality for ciphers 2026-05-18 01:59:02 +08:00
shuaiplus 776408e9d0 feat: enhance SSH key handling with Ed25519 support and PEM formatting 2026-05-16 16:34:06 +08:00
shuaiplus e641da517d feat: add uriChecksum handling and sha256Base64 function for enhanced security 2026-05-16 16:22:43 +08:00
shuaiplus b7878ffe01 feat: improve scrollbar styles and dark mode compatibility 2026-05-15 19:12:40 +08:00
shuaiplus bbad9d60a7 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-05-15 18:28:09 +08:00
shuaiplus ed58467766 feat: enhance authorized devices table layout and styling 2026-05-15 18:28:05 +08:00
ageskyandShuai 2f911e66a6 Update README.md
修改一处描述错误
2026-05-15 11:12:47 +08:00
shuaiplus d06e050162 feat: Updated visual rapid deployment instructions, added JWT_SECRET settings and Workers custom domain prompts 2026-05-14 22:54:54 +08:00
shuaiplus d0dc31ce86 feat: enhance attachment metadata handling and add change password URI support 2026-05-14 22:46:29 +08:00
shuaiplus f64abaa75d feat: enhance search functionality by including cipher ID in search text 2026-05-14 10:52:11 +08:00
shuaiplus 7312086f92 feat: add restore functionality for deleted items with corresponding UI updates 2026-05-14 10:40:32 +08:00
shuaiplus 3e4c104e1d feat: added logging system 2026-05-14 02:42:15 +08:00
shuaiplus 17ceec45b1 feat: implement user and device cache invalidation in AuthService 2026-05-12 19:12:53 +08:00
shuaiplus 2685741386 feat: add permanent trust functionality for devices with corresponding API and UI updates 2026-05-12 18:01:04 +08:00
shuaiplus 83a1fc2376 feat: enhance TOTP settings UI with improved layout and status indication 2026-05-12 15:55:05 +08:00
shuaiplus 06431c4145 feat: enhance mobile responsiveness for management routes and table layout 2026-05-12 15:16:17 +08:00
shuaiplus 700910099b feat: adjust eye button positioning and hover effect for password toggle 2026-05-12 00:22:48 +08:00
shuaiplus 6b671450a8 feat: update version to 1.5.2 in package.json, package-lock.json, and app-version.ts 2026-05-12 00:11:08 +08:00
shuaiplus c0df6d1c16 feat: update styling for sensitive actions module to enhance UI consistency 2026-05-11 23:49:47 +08:00
shuaiplus 35f9512d94 feat: enhance admin invites UI and improve styling for better usability 2026-05-11 20:22:47 +08:00
shuaiplus 9e39161fc7 Add new payment logo SVGs for Discover, JCB, Maestro, Mastercard, UnionPay, and Visa
- Added discover.svg for Discover card logo.
- Added jcb.svg for JCB card logo.
- Added maestro.svg for Maestro card logo.
- Added mastercard.svg for Mastercard logo.
- Added unionpay.svg for UnionPay logo.
- Added visa.svg for Visa card logo.
2026-05-10 23:33:41 +08:00
shuaiplus 7c58282e42 feat: add registration invite code handling and improve error translations
- Updated AuthViews component to conditionally show invite code field based on registrationInviteRequired prop.
- Enhanced error handling in auth API functions to use translateServerError for better user feedback.
- Added new translations for various server error messages in English, Spanish, Russian, Chinese (Simplified and Traditional).
- Modified demo initial bootstrap state to include registrationInviteRequired flag.
- Updated types to include registrationInviteRequired in WebBootstrapResponse.
2026-05-10 23:07:07 +08:00
shuaiplus e0d81f2733 feat: reset password visibility and history state on cipher selection change 2026-05-10 19:57:32 +08:00
shuaiplus 1d23b3fe5e feat: add wiki directory to .gitignore for better file management 2026-05-10 19:02:56 +08:00
shuaiplus a0d4d7a1ff feat: update custom field input to textarea for better usability; enhance styles for improved display 2026-05-10 18:17:09 +08:00
shuaiplus 2f1b61e883 feat: update backup recommendations UI; enhance styles and structure for better user experience 2026-05-10 01:54:12 +08:00
shuaiplus 4e62c90700 feat: enhance website icon loading logic; implement error handling and timeout management 2026-05-09 23:46:33 +08:00
shuaiplus 7afb496eb0 feat: enhance website icon loading mechanism; implement icon loading state management and error handling 2026-05-09 23:00:56 +08:00
shuaiplus 5809e3eebc feat: remove drag-and-drop functionality for TOTP and website rows; update styles and translations for improved user experience 2026-05-08 16:09:02 +08:00
shuaiplus 2e9bbe6801 feat: add resourcePriorityPlugin for enhanced resource loading; update chunking strategy in Vite config 2026-05-08 01:20:00 +08:00
shuaiplus dc0eec7c54 feat: preload DomainRulesPage component for authenticated workspace and demo experience 2026-05-07 23:26:48 +08:00
shuaiplus a0605299f0 feat: implement navigation layout options and styles in AppAuthenticatedShell component; add translations for navigation layout in multiple languages 2026-05-07 23:20:30 +08:00
shuaiplus db68437a0b feat: add admin pagination styling to AdminPage component 2026-05-07 23:00:40 +08:00
shuaiplus 77d8411ea9 feat: add search index headers and robots.txt generation for SEO control 2026-05-07 22:31:15 +08:00
shuaiplus 0c1ab3db48 feat: update development and deployment scripts to include build step; refactor navigation links in AppAuthenticatedShell component 2026-05-07 22:23:39 +08:00
shuaiplus 6cc6e94b91 feat: update README and README_EN to improve layout and accessibility of links 2026-05-07 20:56:47 +08:00
shuaiplus 37ae493fa7 feat: add contributing guidelines and pull request template; update schema comments and documentation 2026-05-07 20:29:39 +08:00
shuaiplus 33f7c5d88a feat: update schema synchronization note and add device fields to refresh_tokens and devices tables 2026-05-07 19:49:38 +08:00
shuaiplus c6c8979772 feat: include domain settings count validation in backup import functions 2026-05-07 19:43:06 +08:00
shuaiplus a00279f47d feat: add domain settings support in backup import and export processes 2026-05-07 19:36:32 +08:00
shuaiplus 669d7ef242 feat: add function to export portable backup settings envelope 2026-05-07 19:23:22 +08:00
shuaiplus 97d2117e15 feat: enhance TOTP configuration parsing with algorithm, digits, and period options 2026-05-06 22:23:26 +08:00
shuaiplus 429b747710 feat: add mobile detail sheet styles and improve text overflow handling in vault 2026-05-06 22:11:14 +08:00
shuaiplus a06853835d feat: improve JSON formatting for global domains and custom domains 2026-05-06 01:20:20 +08:00
shuaiplus c4ff063865 feat: format rules JSON output for better readability 2026-05-06 01:11:57 +08:00
shuaiplus 70b0a3a394 feat: add NodeWarden-compat to .gitignore 2026-05-06 00:50:45 +08:00
shuaiplus e7c07fda4e feat: enhance navigation with collapsible groups and improve styles 2026-05-06 00:47:18 +08:00
shuaiplus 0a001bebcc feat: add domain rules management feature
- Introduced a new DomainRulesPage component for managing custom and global equivalent domains.
- Updated AppMainRoutes to include a route for domain rules.
- Added API functions to fetch and save domain rules.
- Enhanced localization with new strings for domain rules in multiple languages.
- Updated styles for the new domain rules interface and ensured responsiveness.
- Added types for domain rules in the TypeScript definitions.
2026-05-06 00:33:09 +08:00
225 changed files with 57950 additions and 5263 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx vite *)",
"Bash(npx tsc *)"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
-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
contact_links:
- name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki
url: https://nodewarden.app
about: |
Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
+31
View File
@@ -0,0 +1,31 @@
## Summary
<!-- What changed and why? -->
## Change Type
- [ ] Bug fix
- [ ] Feature
- [ ] Compatibility update
- [ ] Documentation
- [ ] Refactor
## Cross-File Checklist
- [ ] I read `CONTRIBUTING.md`.
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
- [ ] User-facing text changes, if any, updated all locale files.
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
## Checks
- [ ] `npx tsc -p tsconfig.json --noEmit`
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
- [ ] `npm run i18n:validate`
- [ ] `npm run build`
## Notes
<!-- Anything reviewers should pay special attention to? -->
+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: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
},
en: {
filename: 'security-report.md',
switcher: 'English | [中文](security-report-cn.md)',
title: '🛡️ Security Audit & Transparency Report',
grade: 'Security Grade',
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
auditTime: '📅 Audit Time',
runId: '📝 Run ID',
env: '🛠️ Environment',
dashboard: '📉 Real-time Security Dashboard',
tool: 'Tool',
status: 'Status',
findings: 'Findings',
leaks: 'Leaks',
vulns: 'Vulns',
alerts: 'Alerts',
coverageTitle: '🔍 Scan Coverage',
module: 'Module',
auditedFiles: 'Audited Files',
coverage: 'Coverage',
detailedFindings: '🔍 Detailed Findings',
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
snykTitle: '📦 Third-party Dependencies',
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
package: 'Package',
severity: 'Severity',
description: 'Description',
fixPlan: 'Fix Plan',
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
codeqlSummary: '#### Summary',
rulesChecked: 'Rules Checked',
totalAlerts: 'Total Alerts',
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
ruleId: 'Rule ID',
level: 'Level',
location: 'Location',
auditedList: '📂 Audited File List',
guideTitle: '⚠️ Action Guide',
guideDesc: 'If you see a **FAIL** status or serious code issues:',
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
trivySafe: '✅ **SAFE**: No container configuration defects found.'
}
};
}
// --- Data Parsers ---
async parseCodeQL() {
const sarifPath = 'sarif-results';
if (!fs.existsSync(sarifPath)) return;
const files = this.globFiles(sarifPath, '.sarif');
let totalAlerts = 0;
let rulesSet = new Set();
let findings = [];
let artifactUris = new Set();
for (const file of files) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const run of data.runs || []) {
// Collect Rules
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
(run.tool.extensions || []).forEach(ext => {
(ext.rules || []).forEach(r => rulesSet.add(r.id));
});
// Collect Results
for (const res of run.results || []) {
totalAlerts++;
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
findings.push({
id: res.ruleId,
level: res.level || 'warning',
path: loc.artifactLocation?.uri || 'Global',
line: loc.region?.startLine || '-',
message: res.message?.text || 'No description'
});
}
// Track Coverage (Deduplicated)
(run.artifacts || []).forEach(art => {
const uri = art.location?.uri || '';
if (uri) artifactUris.add(uri);
});
}
}
this.results.artifactUris = Array.from(artifactUris).sort();
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
this.results.codeql.alertCount = totalAlerts;
this.results.codeql.rulesCount = rulesSet.size;
this.results.codeql.findings = findings;
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
}
async parseSnyk() {
const jsonPath = 'snyk_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const projects = Array.isArray(data) ? data : [data];
let vulnTotal = 0;
let findings = [];
for (const proj of projects) {
const vulns = proj.vulnerabilities || [];
vulnTotal += vulns.length;
vulns.forEach(v => {
findings.push({
pkg: `${v.packageName}@${v.version}`,
severity: v.severity,
title: v.title,
url: v.url,
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
});
});
}
this.results.snyk.vulnCount = vulnTotal;
this.results.snyk.findings = findings;
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
} catch (e) {
console.error('Error parsing Snyk JSON:', e.message);
}
}
async parseGitleaks() {
const files = this.globFiles('.', 'results.sarif');
if (files.length === 0) return;
try {
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
let leaks = 0;
let findings = [];
for (const run of data.runs || []) {
for (const res of run.results || []) {
leaks++;
findings.push({
id: res.ruleId,
message: res.message.text,
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
});
}
}
this.results.gitleaks.leaksCount = leaks;
this.results.gitleaks.findings = findings;
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
} catch (e) {
console.error('Error parsing Gitleaks SARIF:', e.message);
}
}
async parseTrivy() {
const jsonPath = 'trivy_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let misconfigs = 0;
let findings = [];
(data.Results || []).forEach(res => {
(res.Misconfigurations || []).forEach(m => {
misconfigs++;
findings.push({
id: m.ID,
severity: m.Severity,
title: m.Title,
message: m.Message,
status: m.Status,
target: res.Target
});
});
});
this.results.trivy.misconfigCount = misconfigs;
this.results.trivy.findings = findings;
if (misconfigs > 0) this.results.trivy.status = 'WARN';
} catch (e) {
console.error('Error parsing Trivy JSON:', e.message);
}
}
generateTable(type, t) {
let files = [];
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
if (files.length === 0) return `> ${t.noFiles}\n`;
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
files.forEach(f => {
const filename = path.basename(f);
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
});
return table;
}
// --- Renderers ---
generateMarkdown(localeKey) {
const { codeql, snyk, gitleaks, coverage } = this.results;
const t = this.locales[localeKey];
// Calculate Grade
let grade = 'A+';
let gradeColor = 'success';
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
const badge = (label, value, color) => `![${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
+60
View File
@@ -0,0 +1,60 @@
name: Sync Bitwarden global domains
on:
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
inputs:
bitwarden_ref:
description: "bitwarden/server ref to sync"
required: false
default: "main"
type: string
permissions:
contents: write
pull-requests: write
jobs:
sync-global-domains:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Sync generated Bitwarden domains
env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with:
branch: chore/sync-bitwarden-global-domains
delete-branch: true
title: "chore: sync Bitwarden global domain rules"
commit-message: "chore: sync Bitwarden global domain rules"
body: |
Automated sync from bitwarden/server.
This PR only updates:
- `src/static/global_domains.bitwarden.json`
- `src/static/global_domains.bitwarden.meta.json`
`src/static/global_domains.custom.json` is intentionally left untouched.
add-paths: |
src/static/global_domains.bitwarden.json
src/static/global_domains.bitwarden.meta.json
-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
+22 -2
View File
@@ -18,6 +18,7 @@ build/
.idea/
*.swp
*.swo
docs/
# OS
.DS_Store
@@ -26,7 +27,7 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
.vite-tailwind.err
# Environment
.env
.env.local
@@ -40,7 +41,26 @@ npm-debug.log*
tmp/
.tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/
nodewarden-wiki/
wiki/
AGENTS.md
settings.json
.claude/
NodeWarden-compat/
.codex-upstream/
.codex-upstream/bitwarden-server/
.codex-upstream/bitwarden-clients/
.codex-upstream/bitwarden-web/
.codex-upstream/bitwarden-browser/
.reasonix/
.upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json
opencode.jsonc
.cursor/
+133
View File
@@ -0,0 +1,133 @@
# Contributing to NodeWarden
Thanks for taking the time to improve NodeWarden.
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
Small changes can affect official clients, backups, migrations, or locale files,
so please keep changes focused and check the related parts of the project.
## Before Opening an Issue
For bug reports, include enough detail for someone else to reproduce the problem:
- The client or browser you used.
- The page, API route, or action that failed.
- Screenshots, logs, or the exact error message.
- Whether the problem happened after sync, import, export, restore, upgrade, or
a fresh deployment.
Please do not report NodeWarden-specific problems to the official Bitwarden
team. This project is independent from Bitwarden.
## Pull Request Guidelines
Keep pull requests small enough to review. A good PR should explain:
- What changed and why.
- What user-facing behavior changed.
- Which related areas were checked.
- Which commands were run before submitting.
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
needed before the real fix, mention that clearly in the PR.
## Areas That Need Extra Care
Some parts of the codebase are deliberately connected. When changing one of
these areas, check the related files before calling the work complete.
### Database Changes
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
lives in `migrations/0001_init.sql`.
If you add or change a table, column, or index:
- Update both schema files.
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
- Decide whether the data should be included in instance backup.
### Backup And Restore
Backup export and restore are whitelist-based. This protects old backups from
breaking when fields are removed and prevents transient or secret runtime data
from being exported by accident.
When adding persistent data, check:
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `webapp/src/lib/api/backup.ts`
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
retired sensitive fields such as `users.api_key`.
### Secrets And Provider Settings
Provider credentials must not be stored or exported as plain config JSON. Follow
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
document a replacement design before changing it.
### Bitwarden Client Compatibility
Official Bitwarden clients may send or expect fields that are not used directly
by the web vault. Cipher and sync changes should preserve unknown client fields
unless they are known-invalid or server-owned.
Check these files when changing vault item shape or sync behavior:
- `src/handlers/ciphers.ts`
- `src/handlers/sync.ts`
- `src/services/storage-cipher-repo.ts`
### Domain Rules
Equivalent-domain settings store both client/UI rule state and derived active
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
`excluded_global_equivalent_domains` as duplicates without a migration and
compatibility plan.
### Accounts And Passwords
`users.master_password_hash` is for server-side login verification. It is not the
vault decryption key. Password changes, key material, `securityStamp`, and
refresh-token revocation must stay aligned.
Password hints are reminders, not recovery secrets. They must never contain the
master password, recovery codes, API keys, or anything that directly unlocks the
vault.
### i18n
Locale files are complete standalone bundles. When adding or changing user-facing
text, keep every locale in sync and run the validation script.
For new locales, update:
- `webapp/src/lib/i18n.ts`
- `webapp/src/lib/i18n/locales/*`
- `scripts/i18n-utils.cjs`
## Recommended Checks
For most backend or shared changes:
```sh
npx tsc -p tsconfig.json --noEmit
npm run build
```
For webapp text or locale changes:
```sh
npm run i18n:validate
npx tsc -p webapp/tsconfig.json --noEmit
npm run build
```
For documentation-only changes:
```sh
git diff --check
```
+82 -93
View File
@@ -3,89 +3,100 @@
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
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="./RELEASE_NOTES.md">更新日志</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
<p align="center">
<a href="./README_ZH.md">中文</a> |
<a href="./CONTRIBUTING.md">Contributing</a> |
<a href="https://nodewarden.app">Official wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
> **Disclaimer**
> This project is for learning and discussion purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Bitwarden 官方服务端能力对比
## Feature comparison with the official Bitwarden server
| 能力 | Bitwarden | NodeWarden | 说明 |
| Feature | Bitwarden Free | NodeWarden | Notes |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 全量同步 `/api/sync` | | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | | ✅ | **支持 WebDAV / E3 定时备份** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
| TOTP | | ✅ | Includes `steam://` support |
| **PWA / offline** | | ✅ | **Installable, offline** |
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
| API keys | ✅ | ✅ | CLI keys; create and rotate |
| Login 2FA | | ✅ | TOTP, YubiKey, Passkey |
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
| Real-time push sync | ✅ | ✅ | All device sync |
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
| **Multi-user** | ✅ | ✅ | Invite-code registration |
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
---
## 已测试客户端
## Tested clients
- ✅ Windows 桌面端
-手机 App
-浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
- ✅ Windows desktop
-Mobile app
-Browser extension
- ✅ Linux desktop
- ⚠️ macOS desktop not fully verified yet
---
## 网页部署
## Visual quick deploy
1. Fork the NodeWarden repository to your GitHub account
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. Choose **Continue with GitHub** and select your fork
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
- For KV mode, change the deploy command to `npm run deploy:kv`
5. After deployment finishes, open the generated Workers URL
- 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).
- 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.
- 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.
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 `Continue with GitHub`
4. 选择你刚刚 Fork 的仓库
5. 保持默认配置继续部署
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
7. 等部署完成后,打开生成的 Workers 域名
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> Default R2 vs optional KV:
> | Storage | Card required | Max single attachment / Send file | Free tier |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
## How to update
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork****Update branch**
## CLI 部署
## CLI deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
@@ -94,68 +105,46 @@ cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
# Default: R2 mode
npm run deploy
# 可选:KV 模式
# Optional: KV mode
npm run deploy:kv
# 本地开发
# Local development
npm run dev
npm run dev:kv
```
---
## 云端备份说明
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
## License
LGPL-3.0 License
---
## 致谢
## Credits
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
- [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
---
## Contributors
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
[![Star History 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)
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
-153
View File
@@ -1,153 +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="./RELEASE_NOTES.md">Release Notes</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
</p>
中文说明:<a href="./README.md"><code>README.md</code></a>
> **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** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client 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
```
---
## Cloud Backup Notes
- Remote backup supports **WebDAV** and **E3**
- 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)
+149
View File
@@ -0,0 +1,149 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 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/) - 无服务器平台
---
## 贡献者
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+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.
+109 -2
View File
@@ -1,8 +1,14 @@
PRAGMA foreign_keys = ON;
-- IMPORTANT:
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
-- This is the initial D1 schema. Keep it in sync with
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
-- Any new table/column/index must be added to both places together.
--
-- WHEN CHANGING THIS:
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
-- - If the new table stores persistent data, update backup export/import.
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
@@ -25,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT,
totp_recovery_code TEXT,
api_key TEXT,
@@ -33,6 +39,15 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS domain_settings (
user_id TEXT PRIMARY KEY,
equivalent_domains TEXT NOT NULL DEFAULT '[]',
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Per-user sync revision date
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
@@ -115,6 +130,13 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
device_identifier TEXT,
device_session_stamp TEXT,
security_stamp TEXT,
created_at INTEGER,
last_used_at INTEGER,
absolute_expires_at INTEGER,
client_type TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
@@ -137,6 +159,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
action TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
target_type TEXT,
target_id TEXT,
metadata TEXT,
@@ -145,6 +169,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL,
@@ -155,6 +181,10 @@ CREATE TABLE IF NOT EXISTS devices (
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,
@@ -164,6 +194,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_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 (
token TEXT PRIMARY KEY,
@@ -175,6 +233,55 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'login',
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
ON webauthn_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
ON webauthn_credentials(user_id, updated_at);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
challenge_hash TEXT PRIMARY KEY,
scope TEXT NOT NULL,
user_id TEXT,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
ON webauthn_challenges(user_id, scope);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY,
+1798 -1078
View File
File diff suppressed because it is too large Load Diff
+29 -23
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.5.1",
"version": "1.7.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -12,10 +12,11 @@
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
@@ -41,30 +42,35 @@
}
}
},
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"opencc-js": "^1.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"wrangler": "^4.71.0"
"@cloudflare/workers-types": "^5.20260712.1",
"@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1",
"autoprefixer": "^10.5.2",
"opencc-js": "^1.3.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"wrangler": "^4.105.0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
"wouter": "^3.10.0"
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Make `deploy:kv` idempotent across repeated builds.
*
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
* by name. The template ships without an id so fresh accounts can provision one
* on first deploy. In non-interactive builds, wrangler may try to create the
* same namespace again on later builds and fail with code 10014.
*/
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
const BINDING = 'ATTACHMENTS_KV';
const wrangler = (args) =>
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
function bindingBlockHasId(toml) {
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
return block ? /^\s*id\s*=/m.test(block) : false;
}
function expectedTitle(toml) {
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
}
function resolveId(title) {
const list = JSON.parse(wrangler('kv namespace list'));
const hit =
list.find((namespace) => namespace.title === title) ||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
if (hit) {
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
return hit.id;
}
const out = wrangler(`kv namespace create "${title}"`);
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
return id;
}
function main() {
let toml = fs.readFileSync(CONFIG, 'utf8');
if (bindingBlockHasId(toml)) {
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
return;
}
const id = resolveId(expectedTitle(toml));
toml = toml.replace(
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
`$1\nid = "${id}"`
);
fs.writeFileSync(CONFIG, toml);
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
}
main();
+8
View File
@@ -2,6 +2,9 @@ const fs = require('fs');
const path = require('path');
const vm = require('vm');
// CONTRACT:
// This list is the script-side locale source of truth. Keep it in sync with
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
const localeFiles = [
@@ -10,6 +13,11 @@ const localeFiles = [
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'],
['de', 'de.ts', 'de', 'German'],
['fr', 'fr.ts', 'fr', 'French'],
['it', 'it.ts', 'it', 'Italian'],
['sv', 'sv.ts', 'sv', 'Swedish'],
];
function readLocale(fileName, variableName) {
+16 -1
View File
@@ -1,5 +1,9 @@
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
// CONTRACT:
// This is the authoritative locale consistency gate. It checks key parity,
// placeholder parity, and accidental mostly-English locale files. Run after any
// user-facing text or locale-file change.
const locales = Object.fromEntries(
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
);
@@ -18,6 +22,17 @@ const intentionallyEnglishKeys = new Set([
'txt_dash',
'txt_text_3',
]);
const intentionallyEnglishPrefixes = [
'txt_log_action_',
'txt_log_meta_',
'txt_log_reason_',
'txt_log_target_type_',
'txt_log_trigger_',
];
function isIntentionallyEnglishKey(key) {
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
}
for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort();
@@ -36,7 +51,7 @@ for (const [locale, table] of Object.entries(locales)) {
}
if (locale !== 'en') {
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
if (sameAsEnglish.length > 40) {
errors.push({
locale,
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const DEFAULT_REF = 'main';
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
function parseArgs(argv) {
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--ref' && argv[i + 1]) {
args.ref = argv[i + 1];
i += 1;
} else if (arg.startsWith('--ref=')) {
args.ref = arg.slice('--ref='.length);
}
}
return args;
}
function rawUrl(ref, filePath) {
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'NodeWarden global domains sync',
Accept: 'text/plain',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
}
return response.text();
}
function parseEnumTypes(source) {
const map = new Map();
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
if (!enumMatch) {
throw new Error('GlobalEquivalentDomainsType enum was not found');
}
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
let match;
while ((match = entryRe.exec(body)) !== null) {
map.set(match[1], Number(match[2]));
}
if (!map.size) {
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
}
return map;
}
function parseStringList(source) {
const domains = [];
const stringRe = /"((?:\\.|[^"\\])*)"/g;
let match;
while ((match = stringRe.exec(source)) !== null) {
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
}
return Array.from(new Set(domains.filter(Boolean)));
}
function parseGlobalDomains(source, enumTypes) {
const out = [];
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
let match;
while ((match = addRe.exec(source)) !== null) {
const name = match[1];
const type = enumTypes.get(name);
if (!Number.isInteger(type)) {
throw new Error(`GlobalDomains references unknown enum value ${name}`);
}
const domains = parseStringList(match[2]);
if (domains.length < 2) {
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
}
out.push({
type,
domains,
excluded: false,
});
}
if (!out.length) {
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
}
return out;
}
function formatRulesJson(rules) {
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
}
function formatMetaJson(meta) {
return JSON.stringify(meta, null, 2);
}
const { ref } = parseArgs(process.argv.slice(2));
const enumUrl = rawUrl(ref, ENUM_PATH);
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
const [enumSource, staticStoreSource] = await Promise.all([
fetchText(enumUrl),
fetchText(staticStoreUrl),
]);
const enumTypes = parseEnumTypes(enumSource);
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
const rulesJson = formatRulesJson(rules);
async function readJsonFile(filePath) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch {
return null;
}
}
const existingRules = await readJsonFile(OUT_FILE);
const existingMeta = await readJsonFile(META_FILE);
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
const unchangedRef = existingMeta?.ref === ref;
const meta = {
source: 'https://github.com/bitwarden/server',
ref,
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
? existingMeta.generatedAt
: new Date().toISOString(),
rulesCount: rules.length,
domainsCount,
sourceFiles: [
ENUM_PATH,
STATIC_STORE_PATH,
],
sourceUrls: [
enumUrl,
staticStoreUrl,
],
};
await mkdir(OUTPUT_DIR, { recursive: true });
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.5.1';
export const APP_VERSION = '1.7.4';
+15 -3
View File
@@ -1,15 +1,26 @@
// Shared backup settings types used by both Worker and webapp code.
//
// CONTRACT:
// Keep this file serializable and provider-neutral. Runtime state is operational
// metadata; destination fields can contain provider credentials and must be
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
// User-facing provider names should use canonical values here. Legacy aliases
// belong in backend normalization, not in this shared type.
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
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_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav';
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
export interface S3BackupDestination {
endpoint: string;
bucket: string;
addressingStyle: S3BackupAddressingStyle;
region: string;
accessKeyId: string;
secretAccessKey: string;
@@ -95,17 +106,18 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
return {
endpoint: '',
bucket: '',
addressingStyle: 'path-style',
region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '',
secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
};
}
return {
baseUrl: '',
username: '',
password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
};
}
+151
View File
@@ -0,0 +1,151 @@
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
'ac.cn',
'com.cn',
'edu.cn',
'gov.cn',
'net.cn',
'org.cn',
'ah.cn',
'bj.cn',
'cq.cn',
'fj.cn',
'gd.cn',
'gs.cn',
'gx.cn',
'gz.cn',
'ha.cn',
'hb.cn',
'he.cn',
'hi.cn',
'hk.cn',
'hl.cn',
'hn.cn',
'jl.cn',
'js.cn',
'jx.cn',
'ln.cn',
'mo.cn',
'nm.cn',
'nx.cn',
'qh.cn',
'sc.cn',
'sd.cn',
'sh.cn',
'sn.cn',
'sx.cn',
'tj.cn',
'tw.cn',
'xj.cn',
'xz.cn',
'yn.cn',
'zj.cn',
'co.uk',
'org.uk',
'net.uk',
'ac.uk',
'gov.uk',
'com.au',
'net.au',
'org.au',
'edu.au',
'gov.au',
'co.nz',
'org.nz',
'net.nz',
'com.br',
'com.mx',
'com.ar',
'com.tr',
'com.sg',
'com.my',
'com.hk',
'com.tw',
'co.jp',
'ne.jp',
'or.jp',
'co.kr',
'or.kr',
'co.in',
'firm.in',
'net.in',
'org.in',
'co.id',
'or.id',
'web.id',
'co.il',
'org.il',
'co.za',
'com.sa',
'com.ph',
'com.vn',
'com.pk',
'com.bd',
'com.ng',
'github.io',
'pages.dev',
'workers.dev',
'cloudflareaccess.com',
'vercel.app',
'netlify.app',
'web.app',
'firebaseapp.com',
'herokuapp.com',
'fly.dev',
'railway.app',
'render.com',
'onrender.com',
]);
function extractHost(input: string): string {
let raw = input.trim().toLowerCase();
if (!raw) return '';
raw = raw.replace(/\\/g, '/');
try {
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
const parsed = new URL(candidate);
raw = parsed.hostname;
} catch {
raw = raw.split(/[/?#]/, 1)[0] || '';
const atIndex = raw.lastIndexOf('@');
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
if (raw.startsWith('[')) return '';
const colonIndex = raw.lastIndexOf(':');
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
}
return raw
.replace(/^\*+\./, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '');
}
function isValidHost(host: string): boolean {
if (!host || host.length > 253 || !host.includes('.')) return false;
if (host.includes('..') || /[:/\s]/.test(host)) return false;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
return host.split('.').every((label) => (
label.length > 0
&& label.length <= 63
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
));
}
export function normalizeEquivalentDomain(value: unknown): string {
const host = extractHost(String(value || ''));
if (!isValidHost(host)) return '';
const labels = host.split('.');
for (let index = 0; index < labels.length; index += 1) {
const suffix = labels.slice(index).join('.');
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
if (index === 0) return '';
return labels.slice(index - 1).join('.');
}
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
}
export function isValidEquivalentDomain(value: unknown): boolean {
return !!normalizeEquivalentDomain(value);
}
+30 -7
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds.
// 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000,
// Refresh sessions use a reusable opaque token with a sliding idle lifetime.
// 刷新会话使用可复用的随机令牌,并按客户端采用滑动空闲期限
refreshTokenWebSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
refreshTokenDefaultSlidingTtlMs: 30 * 24 * 60 * 60 * 1000,
refreshTokenMobileSlidingTtlMs: 90 * 24 * 60 * 60 * 1000,
// Hard upper bound for one login session, regardless of sliding refreshes.
// 单次登录会话的绝对最长寿命,不因滑动续期突破该上限。
refreshTokenAbsoluteTtlMs: 365 * 24 * 60 * 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
@@ -44,6 +46,9 @@
// Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30,
@@ -59,6 +64,12 @@
// Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30,
// Coarser IP budget; the per-session budget above remains the primary guard.
// 更宽松的 IP 总预算;主要保护仍由每个 refresh session 的预算承担。
refreshTokenRequestsPerIpMinute: 300,
// Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
@@ -145,6 +156,18 @@
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0',
bitwardenServerVersion: '2026.4.1',
// Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
cipherKeyEncryptionFeatureEnabled: true,
},
} as const;
export function getRefreshTokenSlidingTtlMs(clientType?: string | null): number {
const normalized = String(clientType || '').trim().toLowerCase();
if (normalized === 'web') return LIMITS.auth.refreshTokenWebSlidingTtlMs;
if (normalized === 'mobile') return LIMITS.auth.refreshTokenMobileSlidingTtlMs;
return LIMITS.auth.refreshTokenDefaultSlidingTtlMs;
}
+476
View File
@@ -0,0 +1,476 @@
import type { Env } from '../types';
import type { BackupDestinationRecord } from '../services/backup-config';
import {
BACKUP_SCHEDULER_WINDOW_MINUTES,
requireBackupDestination,
hasBackupSlotBetween,
isBackupDueNow,
loadBackupSettings,
} from '../services/backup-config';
import {
createRemoteBackupTransferSession,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
} from '../services/backup-uploader';
import { getBlobObject } from '../services/blob-store';
import { StorageService } from '../services/storage';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
interface BackupJobState {
token: string;
reason: string;
acquiredAt: string;
touchedAt: string;
expiresAtMs: number;
}
interface RemoteAttachmentChunkRequest {
destination: BackupDestinationRecord;
attachments: Array<{
blobName: string;
}>;
}
interface RemoteAttachmentDownloadRequest {
destination: BackupDestinationRecord;
blobName?: string | null;
}
interface RemoteAttachmentBatchDownloadRequest {
destination: BackupDestinationRecord;
blobNames?: string[] | null;
}
interface ConfiguredBackupRunRequest {
actorUserId?: string | null;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
targetDeviceIdentifier?: string | null;
trigger?: 'manual' | 'scheduled';
}
interface RemoteBackupRestoreRequest {
actorUserId?: string | null;
allowChecksumMismatch?: boolean;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
path?: string | null;
replaceExisting?: boolean;
targetDeviceIdentifier?: string | null;
}
function badRequest(message: string, status: number = 400): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
export class BackupTransferRunner {
private lastHeartbeatAt = 0;
constructor(
private readonly state: DurableObjectState,
private readonly env: Env
) {
}
private async acquireJob(reason: string): Promise<string | null> {
const nowMs = Date.now();
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
return null;
}
const token = crypto.randomUUID();
const nowIso = new Date(nowMs).toISOString();
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
token,
reason,
acquiredAt: nowIso,
touchedAt: nowIso,
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
this.lastHeartbeatAt = 0;
return token;
}
private async touchJob(token: string): Promise<void> {
const nowMs = Date.now();
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
this.lastHeartbeatAt = nowMs;
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token !== token) return;
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
...current,
touchedAt: new Date(nowMs).toISOString(),
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
}
private async releaseJob(token: string): Promise<void> {
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token === token) {
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
}
}
private async runConfiguredBackup(request: Request): Promise<Response> {
let body: ConfiguredBackupRunRequest;
try {
body = await request.json<ConfiguredBackupRunRequest>();
} catch {
return badRequest('Backup run payload is invalid');
}
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
const actorUserId = String(body.actorUserId || '').trim() || null;
if (trigger === 'manual' && !actorUserId) {
return badRequest('Manual backup run requires an actor');
}
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const progress = actorUserId
? async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(
this.env,
actorUserId,
event,
String(body.targetDeviceIdentifier || '').trim() || null
);
}
: null;
const result = await executeConfiguredBackup(
this.env,
storage,
actorUserId,
trigger,
body.destinationId || null,
() => this.touchJob(token),
progress,
body.auditMetadata || null
);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
return new Response(JSON.stringify({
object: 'backup-runner-result',
result,
settings,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async runScheduledBackups(): Promise<Response> {
const token = await this.acquireJob('scheduled');
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
let scanStartMs = Date.now();
while (true) {
await this.touchJob(token);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const now = new Date();
const dueDestinations = settings.destinations.filter((destination) =>
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
);
if (!dueDestinations.length) {
break;
}
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await this.touchJob(token);
try {
await executeConfiguredBackup(
this.env,
storage,
null,
'scheduled',
destination.id,
() => this.touchJob(token)
);
completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
}
}
return new Response(JSON.stringify({
ok: true,
completed,
failed: failures.length,
failures,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async restoreRemoteBackup(request: Request): Promise<Response> {
let body: RemoteBackupRestoreRequest;
try {
body = await request.json<RemoteBackupRestoreRequest>();
} catch {
return badRequest('Remote restore payload is invalid');
}
const actorUserId = String(body.actorUserId || '').trim() || null;
if (!actorUserId) {
return badRequest('Remote restore requires an actor');
}
const token = await this.acquireJob(`restore:${actorUserId}`);
if (!token) {
return badRequest('Another backup or restore run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const restoreFileNameFromPath = path.split('/').pop() || path;
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
const replaceExisting = !!body.replaceExisting;
await notifyUserBackupRestoreProgress(
this.env,
actorUserId,
{
operation: 'backup-restore',
source: 'remote',
step: 'remote_fetch_archive',
fileName: restoreFileNameFromPath,
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return badRequest('Remote backup file checksum does not match its filename');
}
const result = await importAndAuditRemoteBackupFile(
this.env,
storage,
actorUserId,
remoteFile,
destination,
path,
replaceExisting,
!checksumOk,
body.auditMetadata || null,
targetDeviceIdentifier,
() => this.touchJob(token)
);
return new Response(JSON.stringify(result.result), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
} finally {
await this.releaseJob(token);
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method !== 'POST') {
return badRequest('Not found', 404);
}
if (url.pathname === '/internal/run-configured-backup') {
return this.runConfiguredBackup(request);
}
if (url.pathname === '/internal/run-scheduled-backups') {
return this.runScheduledBackups();
}
if (url.pathname === '/internal/restore-remote-backup') {
return this.restoreRemoteBackup(request);
}
if (url.pathname === '/internal/download-remote-attachment') {
let body: RemoteAttachmentDownloadRequest;
try {
body = await request.json<RemoteAttachmentDownloadRequest>();
} catch {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid');
}
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
if (!file) {
return badRequest('Remote attachment not found', 404);
}
return new Response(file.bytes, {
status: 200,
headers: {
'Content-Type': file.contentType || 'application/octet-stream',
'Cache-Control': 'no-store',
},
});
}
if (url.pathname === '/internal/download-remote-attachment-batch') {
let body: RemoteAttachmentBatchDownloadRequest;
try {
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
} catch {
return badRequest('Remote attachment batch download payload is invalid');
}
const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim())
.filter(isSafeBackupAttachmentBlobName)
));
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid');
}
const encoder = new TextEncoder();
const entries: Array<{ blobName: string; path: string }> = [];
const files: Record<string, Uint8Array> = {};
for (let i = 0; i < blobNames.length; i += 1) {
const blobName = blobNames[i];
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
if (!file) continue;
const path = `files/${i}.bin`;
entries.push({ blobName, path });
files[path] = file.bytes;
}
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
return new Response(zipSync(files), {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Cache-Control': 'no-store',
},
});
}
if (url.pathname !== '/internal/upload-attachment-chunk') {
return badRequest('Not found', 404);
}
let body: RemoteAttachmentChunkRequest;
try {
body = await request.json<RemoteAttachmentChunkRequest>();
} catch {
return badRequest('Attachment chunk payload is invalid');
}
if (!body?.destination || !Array.isArray(body.attachments)) {
return badRequest('Attachment chunk payload is invalid');
}
const remoteSession = createRemoteBackupTransferSession(body.destination);
let uploaded = 0;
for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid');
}
const object = await getBlobObject(this.env, blobName);
if (!object) {
return badRequest(`Attachment blob missing for ${blobName}`, 409);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
contentType: object.contentType,
});
uploaded += 1;
}
return new Response(JSON.stringify({
ok: true,
uploaded,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
}
+393 -35
View File
@@ -1,17 +1,32 @@
import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types';
import { notifyMobilePush } from '../services/push-relay';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
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_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_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
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 HubKind = 'user' | 'anonymous-auth-request';
interface WsAttachment {
userId: string;
kind: HubKind;
userId: string | null;
authRequestId: string | null;
handshakeComplete: boolean;
protocol: HubProtocol;
deviceIdentifier: string | null;
@@ -137,11 +152,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
function buildSignalRJsonInvocation(
updateType: number,
payload: Record<string, unknown>,
contextId: string | null
contextId: string | null,
target: string = 'ReceiveMessage'
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
target,
arguments: [
{
ContextId: contextId,
@@ -155,15 +171,16 @@ function buildSignalRJsonInvocation(
function buildSignalRMessagePackInvocation(
updateType: number,
messagePayload: Record<string, unknown>,
contextId: string | null
contextId: string | null,
target: string = 'ReceiveMessage'
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
// [type, headers, invocationId, target, arguments, streamIds]
const encodedPayload = encodeMsgPack([
1,
{},
null,
'ReceiveMessage',
target,
[
{
ContextId: contextId,
@@ -171,6 +188,7 @@ function buildSignalRMessagePackInvocation(
Payload: messagePayload,
},
],
[],
]);
return frameSignalRBinary(encodedPayload);
}
@@ -201,7 +219,9 @@ export class NotificationsHub extends DurableObject<Env> {
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
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 payload = body?.payload && typeof body.payload === 'object'
? body.payload
@@ -213,6 +233,20 @@ export class NotificationsHub extends DurableObject<Env> {
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
userId?: string;
authRequestId?: string;
contextId?: string | null;
} | null;
const userId = String(body?.userId || '').trim();
const authRequestId = String(body?.authRequestId || '').trim();
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200,
@@ -222,7 +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 });
}
@@ -232,8 +266,13 @@ export class NotificationsHub extends DurableObject<Env> {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
if (!requestUserId) {
if (!isAnonymousAuthRequestHub && !requestUserId) {
return new Response('Unauthorized', { status: 401 });
}
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
return new Response('Unauthorized', { status: 401 });
}
@@ -248,7 +287,9 @@ export class NotificationsHub extends DurableObject<Env> {
this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({
userId: requestUserId,
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
userId: isAnonymousAuthRequestHub ? null : requestUserId,
authRequestId: requestAuthRequestId,
handshakeComplete: false,
protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier,
@@ -274,7 +315,6 @@ export class NotificationsHub extends DurableObject<Env> {
attachment.handshakeComplete = true;
ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return;
} catch {
// Ignore malformed pre-handshake payloads.
@@ -293,26 +333,22 @@ export class NotificationsHub extends DurableObject<Env> {
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
void ws;
void code;
void reason;
void wasClean;
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
void ws;
void error;
}
private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>();
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier);
}
return Array.from(out);
@@ -349,16 +385,45 @@ export class NotificationsHub extends DurableObject<Env> {
}
}
private broadcastDeviceStatus(userId: string): void {
this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{
private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (
!attachment?.handshakeComplete ||
attachment.kind !== 'anonymous-auth-request' ||
attachment.authRequestId !== authRequestId
) {
continue;
}
const payload = {
UserId: userId,
Date: new Date().toISOString(),
},
null,
null
);
Id: authRequestId,
};
try {
if (attachment.protocol === 'json') {
ws.send(buildSignalRJsonInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
} else {
ws.send(buildSignalRMessagePackInvocation(
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
payload,
contextId,
'AuthRequestResponseRecieved'
));
}
} catch {
try {
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
}
}
@@ -371,6 +436,243 @@ export function notifyUserVaultSync(
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(
env: Env,
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(
env: Env,
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null,
targetDeviceIdentifier: string | null
targetDeviceIdentifier: string | null,
payloadOverride?: Record<string, unknown> | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
@@ -414,12 +762,22 @@ async function notifyUserUpdate(
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
}),
});
await notifyMobilePush(env, {
userId,
updateType,
revisionDate,
contextId,
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
});
} catch (error) {
console.error('Failed to broadcast realtime notification:', error);
}
+793
View File
@@ -0,0 +1,793 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
import {
accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus,
accountPasskeyTokenTtlMs,
buildWebAuthnPrfOption,
createAccountPasskeyToken,
getAccountPasskeyRpConfig,
isSerializedEncString,
normalizeAccountPasskeyName,
normalizeAuthenticationResponse,
normalizeRegistrationResponse,
normalizeTransports,
sha256Base64Url,
toSimpleWebAuthnCredential,
userHandleToUserId,
userIdToWebAuthnUserId,
verifyAccountPasskeyToken,
} from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { createRecoveryCode } from '../utils/recovery-code';
const MAX_ACCOUNT_PASSKEYS = 5;
const MAX_TWO_FACTOR_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
return parseBodyObject(await request.json());
} catch {
return null;
}
}
async function verifyUserSecret(
env: Env,
user: User,
body: Record<string, any>
): Promise<boolean> {
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
if (!secret) return false;
const storedHash = String(user.masterPasswordHash || '').trim();
if (!storedHash) return false;
const auth = new AuthService(env);
return auth.verifyPassword(secret, storedHash, user.email);
}
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
const err = error instanceof Error ? error : null;
console.error('Account passkey handler failed', {
stage,
name: err?.name || typeof error,
message: err?.message || String(error),
stack: err?.stack,
...details,
});
}
function passkeySetupStageMessage(stage: string): string {
if (stage === 'verify_master_password') return 'verifying master password';
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
if (stage === 'generate_options') return 'generating passkey options';
if (stage === 'save_challenge') return 'saving passkey challenge';
if (stage === 'create_token') return 'creating passkey challenge token';
return 'preparing passkey setup';
}
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
}
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
return {
Enabled: credentials.length > 0,
enabled: credentials.length > 0,
Keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
Object: 'twoFactorWebAuthn',
object: 'twoFactorWebAuthn',
};
}
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
} {
if (!hasCompletePrfKeySet(body)) {
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
}
const encryptedUserKey = String(body.encryptedUserKey).trim();
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
throw new Error('Invalid encrypted key set');
}
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
}
async function saveChallenge(
storage: StorageService,
scope: AccountPasskeyChallengeScope,
challenge: string,
userId: string | null
): Promise<void> {
const now = Date.now();
await storage.saveAccountPasskeyChallenge({
challengeHash: await sha256Base64Url(challenge),
scope,
userId,
expiresAt: now + accountPasskeyTokenTtlMs(scope),
usedAt: null,
createdAt: now,
});
}
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: [],
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'Authentication', options.challenge, null);
const token = await createAccountPasskeyToken(env, {
scope: 'Authentication',
challenge: options.challenge,
userId: null,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function assertAccountPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
input: {
token: string;
deviceResponse: unknown;
scope: 'Authentication' | 'UpdateKeySet';
expectedUserId?: string | null;
}
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
if (!payload) {
throw new Error('Passkey challenge token is invalid or expired');
}
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
throw new Error('Passkey challenge token does not match this user');
}
const response = normalizeAuthenticationResponse(input.deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(
challengeHash,
input.scope,
payload.userId,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential) {
throw new Error('Passkey is not registered for this server');
}
if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user');
}
if (credential.purpose !== 'login') {
throw new Error('Passkey is not registered for login');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
if (!resolvedUserId || resolvedUserId !== credential.userId) {
throw new Error('Passkey user handle does not match this credential');
}
const user = await storage.getUserById(resolvedUserId);
if (!user || user.status !== 'active') {
throw new Error('Passkey user is not available');
}
const { origins } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: true,
advancedFIDOConfig: { userVerification: 'required' },
});
if (!verification.verified || !verification.authenticationInfo.userVerified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return { user, credential };
}
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
return jsonResponse({
data: credentials.map(accountPasskeyCredentialToResponse),
Data: credentials.map(accountPasskeyCredentialToResponse),
object: 'list',
Object: 'list',
continuationToken: null,
ContinuationToken: null,
});
}
export async function buildTwoFactorPasskeyAssertionOptions(
request: Request,
env: Env,
storage: StorageService,
user: User
): Promise<Record<string, unknown> | null> {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (!credentials.length) return null;
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'discouraged',
timeout: 60000,
});
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
return options as unknown as Record<string, unknown>;
}
export async function assertTwoFactorPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
user: User,
deviceResponse: unknown
): Promise<AccountPasskeyCredential> {
const response = normalizeAuthenticationResponse(deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
throw new Error('Passkey is not registered for two-step login');
}
const challenge = readAuthenticationChallenge(response);
if (!challenge) {
throw new Error('Passkey assertion challenge is missing');
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorAuthentication',
user.id,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: false,
});
if (!verification.verified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return credential;
}
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'discouraged',
requireResidentKey: false,
userVerification: 'discouraged',
},
});
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
return jsonResponse(options);
}
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const challenge = readRegistrationChallenge(registrationResponse);
if (!challenge) {
return errorResponse('Passkey challenge is missing', 400);
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorCreate',
userId,
Date.now()
);
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserPresence: true,
requireUserVerification: false,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const transports = normalizeTransports(registrationResponse.response.transports);
await storage.saveAccountPasskeyCredential({
id: generateUUID(),
userId,
purpose: 'twoFactor',
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
supportsPrf: false,
createdAt: now,
updatedAt: now,
});
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = now;
await storage.saveUser(user);
}
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.enable',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: null,
metadata: auditRequestMetadata(request),
});
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const requestedId = Number(body.id ?? body.Id);
if (!Number.isInteger(requestedId) || requestedId <= 0) {
return errorResponse('Invalid key id', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length < 2) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const credential = credentials[requestedId - 1];
if (!credential) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.delete',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let stage = 'verify_master_password';
try {
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
stage = 'load_existing_credentials';
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
stage = 'generate_options';
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
});
(options as any).extensions = {
...((options as any).extensions || {}),
prf: {},
};
stage = 'save_challenge';
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
stage = 'create_token';
const token = await createAccountPasskeyToken(env, {
scope: 'CreateCredential',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
} catch (error) {
logAccountPasskeyHandlerError(stage, error, { userId });
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
}
}
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
const requestedId = String(body.credentialId || body.id || '').trim();
if (requestedId) {
credentials = credentials.filter((credential) => credential.id === requestedId);
if (!credentials.length) return errorResponse('Account passkey not found', 404);
}
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
const token = await createAccountPasskeyToken(env, {
scope: 'UpdateKeySet',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const storage = new StorageService(env.DB);
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
if (!payload || payload.userId !== userId) {
return errorResponse('Passkey challenge token is invalid or expired', 400);
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const { origins } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
requireUserPresence: true,
requireUserVerification: true,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
const transports = normalizeTransports(registrationResponse.response.transports);
const credential: AccountPasskeyCredential = {
id: generateUUID(),
userId,
purpose: 'login',
name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: prfKeySet.encryptedUserKey,
encryptedPublicKey: prfKeySet.encryptedPublicKey,
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
supportsPrf,
createdAt: now,
updatedAt: now,
};
await storage.saveAccountPasskeyCredential(credential);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.create',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
prfStatus: accountPasskeyPrfStatus(credential),
...auditRequestMetadata(request),
},
});
return jsonResponse(accountPasskeyCredentialToResponse(credential));
}
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
return errorResponse('Encrypted passkey key set is required', 400);
}
const storage = new StorageService(env.DB);
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
assertion = await assertAccountPasskeyCredential(request, env, storage, {
token: String(body.token || ''),
deviceResponse: body.deviceResponse,
scope: 'UpdateKeySet',
expectedUserId: userId,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
}
const updated = await storage.updateAccountPasskeyEncryption(
userId,
assertion.credential.credentialId,
prfKeySet.encryptedUserKey,
prfKeySet.encryptedPublicKey,
prfKeySet.encryptedPrivateKey
);
if (!updated) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.encryption.enable',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: assertion.credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
if (!deleted) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.delete',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credentialId,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
return buildWebAuthnPrfOption(credential);
}
+802 -95
View File
File diff suppressed because it is too large Load Diff
+194 -36
View File
@@ -1,13 +1,42 @@
import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -24,16 +53,20 @@ async function writeAuditLog(
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
category: action.startsWith('admin.user.') ? 'security' : 'system',
level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
@@ -64,23 +97,130 @@ export async function handleAdminListUsers(
const storage = new StorageService(env.DB);
const users = await storage.getAllUsers();
return jsonResponse({
data: users.map(user => ({
const data = await Promise.all(users.map(async user => {
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
twoFactorEnabled: !!user.totpSecret,
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
creationDate: user.createdAt,
revisionDate: user.updatedAt,
object: 'user',
})),
};
}));
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
}
// GET /api/admin/logs
export async function handleAdminListAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const url = new URL(request.url);
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
const category = String(url.searchParams.get('category') || '').trim() || null;
const level = String(url.searchParams.get('level') || '').trim() || null;
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
const from = String(url.searchParams.get('from') || '').trim() || null;
const to = String(url.searchParams.get('to') || '').trim() || null;
const storage = new StorageService(env.DB);
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
return jsonResponse({
data: result.logs.map(log => ({
id: log.id,
actorUserId: log.actorUserId,
actorEmail: log.actorEmail,
action: log.action,
category: log.category,
level: log.level,
targetType: log.targetType,
targetId: log.targetId,
targetUserEmail: log.targetUserEmail,
metadata: log.metadata,
createdAt: log.createdAt,
object: 'auditLog',
})),
total: result.total,
limit,
offset,
hasMore: result.hasMore,
object: 'list',
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
});
}
// GET /api/admin/logs/settings
export async function handleAdminGetAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
return jsonResponse({
object: 'auditLogSettings',
...await getAuditLogSettings(storage),
});
}
// PUT /api/admin/logs/settings
export async function handleAdminUpdateAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const storage = new StorageService(env.DB);
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
return jsonResponse({
object: 'auditLogSettings',
...settings,
});
}
// DELETE /api/admin/logs
export async function handleAdminClearAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs();
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
deleted,
}, request);
return jsonResponse({ object: 'auditLogClear', deleted });
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
@@ -92,14 +232,11 @@ export async function handleAdminCreateInvite(
}
const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {};
try {
body = await request.json();
} catch {
body = {};
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const expiresInHours = Number.isFinite(body.expiresInHours)
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7;
const now = new Date();
@@ -115,9 +252,9 @@ export async function handleAdminCreateInvite(
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours,
});
}, request);
return jsonResponse(toInviteResponse(request, invite), 201);
}
@@ -144,7 +281,7 @@ export async function handleAdminListInvites(
}
// DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite(
export async function handleAdminDeleteInvite(
request: Request,
env: Env,
actorUser: User,
@@ -154,13 +291,19 @@ export async function handleAdminRevokeInvite(
return errorResponse('Forbidden', 403);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code);
if (!revoked) {
return errorResponse('Invite not found or already inactive', 404);
const deleted = await storage.deleteInvite(code);
if (!deleted) {
return errorResponse('Invite not found', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 });
}
@@ -170,16 +313,29 @@ export async function handleAdminDeleteAllInvites(
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const 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();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
});
}, request);
return jsonResponse({ deleted }, 200);
}
@@ -195,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403);
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) {
@@ -222,9 +375,10 @@ export async function handleAdminSetUserStatus(
if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id);
}
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
});
}, request);
return jsonResponse({
id: target.id,
@@ -242,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
@@ -250,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
@@ -280,9 +437,10 @@ export async function handleAdminDeleteUser(
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email,
});
targetEmail: target.email,
}, request);
return new Response(null, { status: 204 });
}
+111 -38
View File
@@ -1,16 +1,17 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { Env, Attachment, Cipher } from '../types';
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import {
createAttachmentUploadToken,
createFileDownloadToken,
verifyAttachmentUploadToken,
verifyFileDownloadToken,
} from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
@@ -20,6 +21,7 @@ import {
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -30,6 +32,59 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'attachment';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
@@ -53,6 +108,7 @@ async function runWithConcurrency<T>(
async function processAttachmentUpload(
request: Request,
env: Env,
cipher: Cipher,
attachment: Attachment,
cipherId: string
): Promise<Response> {
@@ -68,6 +124,10 @@ async function processAttachmentUpload(
}
const path = getAttachmentObjectKey(cipherId, attachment.id);
if (await getBlobObject(env, path)) {
return errorResponse('Attachment file has already been uploaded', 409);
}
try {
await putBlobObject(env, path, upload.body, {
size: upload.size,
@@ -94,6 +154,7 @@ async function processAttachmentUpload(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
}
return new Response(null, { status: 201 });
@@ -110,7 +171,7 @@ export async function handleCreateAttachment(
const storage = new StorageService(env.DB);
// 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) {
return errorResponse('Cipher not found', 404);
}
@@ -148,16 +209,17 @@ export async function handleCreateAttachment(
await storage.saveAttachment(attachment);
// Add attachment to cipher
await storage.addAttachmentToCipher(cipherId, attachmentId);
await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
// Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
}
// 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 jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
@@ -186,18 +248,18 @@ export async function handleUploadAttachment(
const storage = new StorageService(env.DB);
// 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) {
return errorResponse('Cipher not found', 404);
}
// Verify attachment exists
const attachment = await storage.getAttachment(attachmentId);
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
return processAttachmentUpload(request, env, attachment, cipherId);
return processAttachmentUpload(request, env, cipher, attachment, cipherId);
}
export async function handlePublicUploadAttachment(
@@ -225,17 +287,17 @@ export async function handlePublicUploadAttachment(
}
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) {
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) {
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}
@@ -250,16 +312,17 @@ export async function handleGetAttachment(
const storage = new StorageService(env.DB);
// 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) {
return errorResponse('Cipher not found', 404);
}
// Verify attachment exists
const attachment = await storage.getAttachment(attachmentId);
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
// Generate short-lived download token
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
@@ -270,12 +333,12 @@ export async function handleGetAttachment(
return jsonResponse({
object: 'attachment',
id: attachment.id,
id: responseAttachment.id,
url: downloadUrl,
fileName: attachment.fileName,
key: attachment.key,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
fileName: responseAttachment.fileName,
key: responseAttachment.key,
size: String(Number(responseAttachment.size) || 0),
sizeName: responseAttachment.sizeName,
});
}
@@ -290,12 +353,12 @@ export async function handleUpdateAttachmentMetadata(
): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachment = await storage.getAttachment(attachmentId);
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
@@ -325,6 +388,7 @@ export async function handleUpdateAttachmentMetadata(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
}
return jsonResponse({
@@ -345,10 +409,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error', 500);
}
const secret = getSafeJwtSecret(env);
if (!secret) return errorResponse('Server configuration error', 500);
const url = new URL(request.url);
const token = url.searchParams.get('token');
@@ -358,7 +420,7 @@ export async function handlePublicDownloadAttachment(
}
// Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
const claims = await verifyFileDownloadToken(token, secret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
@@ -377,22 +439,23 @@ export async function handlePublicDownloadAttachment(
}
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);
if (!firstUse) {
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, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -409,13 +472,13 @@ export async function handleDeleteAttachment(
const storage = new StorageService(env.DB);
// 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) {
return errorResponse('Cipher not found', 404);
}
// Verify attachment exists
const attachment = await storage.getAttachment(attachmentId);
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
@@ -424,20 +487,30 @@ export async function handleDeleteAttachment(
await deleteBlobObject(env, path);
// Delete attachment metadata
await storage.deleteAttachment(attachmentId);
await storage.deleteAttachmentForUser(attachmentId, userId);
// Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
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));
}
+625 -291
View File
File diff suppressed because it is too large Load Diff
+654 -50
View File
File diff suppressed because it is too large Load Diff
+231 -11
View File
@@ -1,11 +1,16 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
@@ -43,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
LastActivityDate: device.lastSeenAt,
lastActivityDate: device.lastSeenAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
@@ -118,6 +125,85 @@ function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
function parseDeviceType(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
// POST /api/devices
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
const storage = new StorageService(env.DB);
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
if (pushToken) {
const device = await storage.getDevice(userId, identifier);
const pushUuid = device?.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: identifier,
type,
pushUuid,
pushToken,
});
}
}
const device = await storage.getDevice(userId, identifier);
if (!device) return errorResponse('Device registration failed', 500);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.register',
category: 'device',
level: 'info',
targetType: 'device',
targetId: identifier,
metadata: auditRequestMetadata(request),
});
return jsonResponse(buildDeviceResponse(device));
}
// POST /api/devices/lost-trust
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request) || {};
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
},
request
);
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
const storage = new StorageService(env.DB);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.lost_trust',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: deviceInfo.deviceIdentifier,
metadata: {
deviceIdentifier: deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
@@ -219,6 +305,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
pushUuid: null,
pushToken: null,
devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
@@ -265,9 +353,50 @@ export async function handleRevokeTrustedDevice(
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
// POST /api/devices/authorized/:deviceIdentifier/permanent
// Upgrades an existing active 2FA remember-token record to permanent trust.
export async function handleTrustDevicePermanently(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
updated,
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
});
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
@@ -280,12 +409,24 @@ export async function handleDeleteDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
@@ -309,16 +450,40 @@ export async function handleUpdateDeviceName(
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.name.update',
category: 'device',
level: 'info',
targetType: 'device',
targetId: normalized,
metadata: { name, ...auditRequestMetadata(request) },
});
return jsonResponse(buildDeviceResponse(device));
}
// DELETE /api/devices
export async function handleDeleteAllDevices(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);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId),
@@ -327,7 +492,17 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
@@ -419,6 +594,15 @@ export async function handleUntrustDevices(
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
@@ -454,28 +638,58 @@ export async function handleDeactivateDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.deactivate',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
// PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
// Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
export async function handleUpdateDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
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 });
}
@@ -501,9 +715,15 @@ export async function handleClearDeviceToken(
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
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 });
}
+85
View File
@@ -0,0 +1,85 @@
import type { Env } from '../types';
import { StorageService } from '../services/storage';
import {
buildDomainsResponse,
customRulesToActiveEquivalentDomains,
normalizeCustomEquivalentDomains,
normalizeEquivalentDomains,
normalizeExcludedGlobalTypes,
} from '../services/domain-rules';
import { errorResponse, jsonResponse } from '../utils/response';
// CONTRACT:
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
// It stores custom rules, then derives equivalentDomains from the non-excluded
// custom rules. Keep this behavior aligned with backup import/export and
// src/services/storage-domain-rules-repo.ts.
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
}
return undefined;
}
async function readPayload(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const settings = await storage.getUserDomainSettings(userId);
return jsonResponse(buildDomainsResponse(
settings.equivalentDomains,
settings.customEquivalentDomains,
settings.excludedGlobalEquivalentDomains
));
}
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const payload = await readPayload(request);
const current = await storage.getUserDomainSettings(userId);
const equivalentDomainsRaw = firstPresent(payload, [
'equivalentDomains',
'EquivalentDomains',
]);
const customEquivalentDomainsRaw = firstPresent(payload, [
'customEquivalentDomains',
'CustomEquivalentDomains',
]);
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
'excludedGlobalEquivalentDomains',
'ExcludedGlobalEquivalentDomains',
// Some older compatible clients send the excluded type list under this key.
'globalEquivalentDomains',
'GlobalEquivalentDomains',
]);
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
? (equivalentDomainsRaw === undefined
? current.customEquivalentDomains
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
? current.excludedGlobalEquivalentDomains
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
const settings = await storage.getUserDomainSettings(userId);
if (!settings) {
return errorResponse('Domain settings unavailable', 500);
}
return jsonResponse(buildDomainsResponse(
settings.equivalentDomains,
settings.customEquivalentDomains,
settings.excludedGlobalEquivalentDomains
));
}
+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);
}
+69 -4
View File
@@ -1,10 +1,16 @@
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 { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
@@ -15,6 +21,27 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeFolderAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'folder',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
return {
@@ -53,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404);
@@ -89,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderCreate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder), 200);
}
@@ -96,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404);
@@ -117,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderUpdate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder));
}
@@ -124,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404);
@@ -134,6 +173,15 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderDelete(env, {
userId,
folderId: id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 });
}
@@ -154,9 +202,26 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
return errorResponse('Folder ids are required', 400);
}
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);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const folder of folders) {
notifyUserFolderDelete(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
}
return new Response(null, { status: 204 });
+571 -112
View File
@@ -1,30 +1,51 @@
import { Env, TokenResponse } from '../types';
import { Env, TokenResponse, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends';
import { registerMobilePushDevice } from '../services/push-relay';
import {
buildAccountKeys,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import {
assertAccountPasskeyCredential,
assertTwoFactorPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
buildTwoFactorPasskeyAssertionOptions,
} 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_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
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';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function identityJsonResponse(data: unknown, status: number = 200): Response {
return jsonResponse(data, status, { 'Cache-Control': 'no-store', Pragma: 'no-cache' });
}
function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
@@ -32,6 +53,82 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null;
}
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function resolveRefreshClientType(request: Request, body: Record<string, string>): string {
if (shouldUseWebSession(request)) return 'web';
const clientId = String(body.client_id || '').trim().toLowerCase();
if (clientId === 'mobile') return 'mobile';
if (clientId === 'browser' || clientId === 'desktop' || clientId === 'cli') return clientId;
return clientId || 'other';
}
async function persistAndResolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
const candidate = await resolveDeviceSession(storage, userId, deviceInfo);
if (!candidate) return null;
await storage.upsertDevice(
userId,
candidate.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
candidate.sessionStamp
);
const persisted = await storage.getDevice(userId, candidate.identifier);
if (!persisted?.sessionStamp) throw new Error('Failed to persist device session');
return { identifier: persisted.deviceIdentifier, sessionStamp: persisted.sessionStamp };
}
function readDevicePushToken(body: Record<string, string>): string {
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 {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
}
@@ -48,16 +145,31 @@ function parseCookieValue(request: Request, name: string): string | null {
return null;
}
function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) {
const value = body[name];
if (value != null) return value;
}
return 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 {
@@ -82,7 +194,7 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
headers.append(
'Set-Cookie',
refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
? buildRefreshCookie(request, refreshToken, Math.floor(getRefreshTokenSlidingTtlMs('web') / 1000))
: buildClearedRefreshCookie(request)
);
return new Response(response.body, {
@@ -114,23 +226,55 @@ function buildPreloginResponse(
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
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 = {
TwoFactorProviders: providers,
TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
MasterPasswordPolicy: masterPasswordPolicyResponse(),
};
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse(
return identityJsonResponse(
{
error: 'invalid_grant',
error_description: message,
@@ -204,24 +348,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
if (!clientIdentifier && grantType !== 'refresh_token') {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'error',
targetType: 'tokenEndpoint',
metadata: { grantType, reason: 'client_ip_missing', ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Authentication is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (grantType === 'password') {
// Login with password
const email = body.username?.toLowerCase();
const passwordHash = body.password;
const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = `${clientIdentifier}:${email}`;
const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields.
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
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -240,11 +397,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
let validatedAuthRequestId: string | null = null;
let 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) {
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return recordFailedLoginAndBuildResponse(
rateLimit,
loginIdentifier,
@@ -252,11 +458,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
// Optional 2FA: enabled only by per-user secret.
// Optional 2FA: enabled by any supported per-user provider.
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -266,7 +473,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
}
let passedByRememberToken = false;
@@ -281,26 +488,68 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
if (!effectiveTotpSecret) {
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);
}
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
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.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
rememberRequested = false;
} else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
@@ -320,27 +569,36 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Persist device only after successful password + (optional) 2FA verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
if (validatedAuthRequestId) {
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
}
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -348,6 +606,119 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: masterPasswordPolicyResponse(),
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'webauthn') {
const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
deviceResponse = JSON.parse(deviceResponse);
} catch {
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
}
}
if (!token || !deviceResponse) {
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
}
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
asserted = await assertAccountPasskeyCredential(request, env, storage, {
token,
deviceResponse,
scope: 'Authentication',
});
} catch (error) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: null,
action: 'auth.passkey.login.failed',
category: 'auth',
level: 'warn',
targetType: 'accountPasskey',
targetId: null,
metadata: {
grantType,
reason: error instanceof Error ? error.message : 'assertion_failed',
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
}
const { user, credential } = asserted;
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.passkey.login.success',
category: 'auth',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
@@ -358,17 +729,17 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
MasterPasswordPolicy: masterPasswordPolicyResponse(),
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
@@ -380,11 +751,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) {
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
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -396,7 +768,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
const uid = clientId.slice(5);
const user = await storage.getUserById(uid);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
@@ -404,36 +775,68 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_api_key',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful client credential verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const refreshToken = await auth.generateRefreshToken(user, deviceSession, resolveRefreshClientType(request, body));
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
@@ -450,9 +853,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
MasterPasswordPolicy: masterPasswordPolicyResponse(),
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
@@ -460,7 +861,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
@@ -477,7 +878,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
return identityJsonResponse(
{
error: 'invalid_request',
error_description: 'send_id is required',
@@ -502,13 +903,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
clientIdentifier || undefined
);
if ('error' in result) {
return result.error;
}
return jsonResponse({
return identityJsonResponse({
access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer',
@@ -516,19 +917,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true,
});
} else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token
const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE)
@@ -538,26 +926,97 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
if (!result) {
const refreshTokenHash = await sha256Hex(refreshToken);
try {
const sessionLimit = await rateLimit.consumeBudget(
`refresh-session:${refreshTokenHash}`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
const ipLimit = clientIdentifier
? await rateLimit.consumeBudget(
`refresh-ip:${clientIdentifier}`,
LIMITS.rateLimit.refreshTokenRequestsPerIpMinute
)
: null;
const rejected = !sessionLimit.allowed ? sessionLimit : (ipLimit && !ipLimit.allowed ? ipLimit : null);
if (rejected) {
const retryAfter = Math.max(1, rejected.retryAfterSeconds || 1);
return identityErrorResponse(
`Rate limit exceeded. Try again in ${retryAfter} seconds.`,
'temporarily_unavailable',
429,
{ 'Retry-After': String(retryAfter) }
);
}
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.rate_limit_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'rate_limit_unavailable', error: error instanceof Error ? error.message : String(error), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!clientIdentifier) {
await safeWriteAuditEvent(env, {
action: 'auth.client_ip.missing',
category: 'auth',
level: 'warn',
targetType: 'refreshToken',
metadata: { grantType, reason: 'client_ip_missing', webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
}
let result: Awaited<ReturnType<AuthService['refreshAccessTokenDetailed']>>;
try {
result = await auth.refreshAccessTokenDetailed(refreshToken);
} catch (error) {
await safeWriteAuditEvent(env, {
action: 'auth.refresh.failed.temporarily_unavailable',
category: 'auth',
level: 'error',
targetType: 'refreshToken',
metadata: { grantType, reason: 'storage_or_worker_error', error: error instanceof Error ? error.message : String(error), webSession: shouldUseWebSession(request), ...auditRequestMetadata(request) },
});
return identityErrorResponse(
'Session refresh is temporarily unavailable',
'temporarily_unavailable',
503,
{ 'Retry-After': '5' }
);
}
if (!result.ok) {
await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null,
action: `auth.refresh.failed.${result.reason}`,
category: 'auth',
level: 'warn',
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
targetId: result.deviceIdentifier ?? null,
metadata: {
grantType,
reason: result.reason,
webSession: shouldUseWebSession(request),
...auditRequestMetadata(request),
},
});
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null)
: invalidResponse;
}
// Keep a short overlap window for old refresh token to absorb
// concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
@@ -565,7 +1024,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
@@ -576,9 +1035,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
MasterPasswordPolicy: masterPasswordPolicyResponse(),
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
@@ -586,9 +1043,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
const baseResponse = identityJsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
}
@@ -621,7 +1078,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
return identityJsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
}
// POST /identity/connect/revocation
@@ -629,7 +1086,6 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
try {
@@ -640,7 +1096,7 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
body = await request.json();
}
} catch {
return new Response(null, { status: 200 });
return new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } });
}
const token = String(body.token || '').trim() || (
@@ -652,7 +1108,10 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
await storage.deleteRefreshToken(token);
}
const baseResponse = new Response(null, { status: 200 });
const baseResponse = new Response(null, {
status: 200,
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
});
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null)
: baseResponse;
+34 -10
View File
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
@@ -17,9 +17,12 @@ interface CiphersImportRequest {
favorite?: boolean;
reprompt?: number;
sshKey?: any | null;
bankAccount?: any | null;
driversLicense?: any | null;
passport?: any | null;
key?: string | null;
login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null;
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
username?: string | null;
password?: string | null;
totp?: string | null;
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
return undefined;
}
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; 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);
}
const folders = importData.folders || [];
const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || [];
const folders = Array.isArray(importData.folders) ? importData.folders : [];
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
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[] = [];
for (let i = 0; i < folders.length; i++) {
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
const folderId = generateUUID();
folderIdMap.set(i, folderId);
const folder: Folder = {
id: folderId,
userId: userId,
name: folders[i].name,
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
createdAt: 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
const cipherFolderMap = new Map<number, string>();
for (const rel of folderRelationships) {
if (!rel || typeof rel !== 'object') continue;
const folderId = folderIdMap.get(rel.value);
if (folderId) {
cipherFolderMap.set(rel.key, folderId);
}
}
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Create ciphers
const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
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 sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
@@ -195,7 +212,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
uris: login.uris?.map((u: any) => ({
...u,
uri: u.uri ?? null,
uriChecksum: null,
uriChecksum: u.uriChecksum ?? null,
match: u.match ?? null,
})) || null,
totp: login.totp ?? null,
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null,
passwordHistory: passwordHistory ?? null,
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,
createdAt: now,
updatedAt: now,
@@ -252,6 +272,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) {
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
}
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
+23
View File
@@ -1,4 +1,6 @@
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 { errorResponse, jsonResponse } from '../utils/response';
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));
}
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));
}
+88 -16
View File
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
import {
getBlobStorageMaxBytes,
getSendFileObjectKey,
getBlobObject,
putBlobObject,
deleteBlobObject,
} from '../services/blob-store';
@@ -16,6 +17,9 @@ import {
formatSize,
getAliasedProp,
normalizeEmails,
notifySendCreateForRequest,
notifySendDeleteForRequest,
notifySendUpdateForRequest,
notifyVaultSyncForRequest,
parseDate,
parseFileLength,
@@ -29,6 +33,30 @@ import {
setSendPassword,
validateDeletionDate,
} from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
async function writeSendAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'send',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
async function processSendFileUpload(
request: Request,
@@ -57,8 +85,13 @@ async function processSendFileUpload(
return upload;
}
const path = getSendFileObjectKey(send.id, fileId);
if (await getBlobObject(env, path)) {
return errorResponse('Send file has already been uploaded', 409);
}
try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
await putBlobObject(env, path, upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
@@ -77,6 +110,7 @@ async function processSendFileUpload(
const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
return new Response(null, { status: 201 });
}
@@ -108,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> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
@@ -184,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString();
const send: Send = {
@@ -227,6 +267,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -307,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString();
const send: Send = {
@@ -350,6 +397,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
@@ -373,7 +421,7 @@ export async function handleGetSendFileUpload(
): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
@@ -408,7 +456,7 @@ export async function handleUploadSendFile(
fileId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
@@ -444,7 +492,7 @@ export async function handlePublicUploadSendFile(
}
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) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
@@ -457,7 +505,7 @@ export async function handlePublicUploadSendFile(
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
@@ -564,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
send.authType = parsedAuthType;
if (parsedAuthType !== SendAuthType.Email) {
send.emails = null;
if (parsedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.authType = parsedAuthType;
send.emails = null;
}
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
@@ -576,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.emails = normalizedEmails;
if (send.emails) {
send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) {
} else if (Number(send.authType) === SendAuthType.Email) {
send.authType = SendAuthType.None;
}
}
@@ -597,14 +649,14 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
@@ -620,6 +672,11 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 });
}
@@ -651,15 +708,21 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const send of sends) {
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
}
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
}
return new Response(null, { status: 200 });
}
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
@@ -669,14 +732,18 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
@@ -687,6 +754,11 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
+41 -13
View File
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
import { sanitizeDownloadContentType } from '../utils/content-type';
import {
createSendAccessToken,
createSendFileDownloadToken,
@@ -21,6 +21,7 @@ import {
getSafeJwtSecret,
hasEmailAuth,
isSendAvailable,
notifySendUpdateForRequest,
notifyVaultSyncForRequest,
parseStoredSendData,
resolveSendFromIdOrAccessId,
@@ -33,6 +34,14 @@ import {
verifySendPasswordHashB64,
} 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> {
const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId);
@@ -59,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
@@ -90,6 +99,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -102,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string,
fileId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return errorResponse('Server configuration error', 500);
}
const safeSecret = getSafeJwtSecret(env);
if (!safeSecret.ok) return safeSecret.response;
const { secret } = safeSecret;
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
@@ -133,7 +142,7 @@ export async function handleAccessSendFile(
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
@@ -163,6 +172,7 @@ export async function handleAccessSendFile(
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
@@ -203,6 +213,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -242,6 +253,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url);
@@ -278,9 +290,14 @@ export async function handleDownloadSendFile(
}
const storage = new StorageService(env.DB);
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
const send = await storage.getSend(sendId);
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
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);
@@ -288,11 +305,19 @@ export async function handleDownloadSendFile(
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, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -303,7 +328,7 @@ export async function issueSendAccessToken(
passwordHashB64?: string | null,
password?: string | null,
rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string
clientIdentifier?: string
): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) {
@@ -343,11 +368,14 @@ export async function issueSendAccessToken(
Object: 'error',
},
},
400
501
),
};
}
const sendPasswordLimitIpKey =
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
if (send.passwordHash) {
if (rateLimit && 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 { notifyUserVaultSync } from '../durable/notifications-hub';
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import {
notifyUserSendCreate,
notifyUserSendDelete,
notifyUserSendUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
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 } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
@@ -105,7 +155,15 @@ export function formatSize(bytes: number): string {
export function parseDate(raw: unknown): Date | 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;
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 } {
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: true, secret };
@@ -384,8 +442,8 @@ export type PublicSendAccessValidationResult =
| { ok: true }
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
export function sendPasswordLimitKey(clientIdentifier: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
}
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> {
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 };
+52 -39
View File
@@ -1,19 +1,34 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import {
buildAccountKeys,
buildUserDecryptionCompat,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
import { buildProfileResponse } from '../utils/profile-response';
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
// CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
// Filtering invalid cipher responses here protects clients from stored rows that
// would otherwise make official apps fail after an HTTP 200 sync.
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
function buildSyncCacheRequest(
request: Request,
userId: string,
revisionDate: string,
accountPasskeyCacheTag: string,
excludeDomains: boolean,
excludeSends: boolean,
preserveRepairableUris: boolean
): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
@@ -37,56 +52,49 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
const [revisionDate, accountPasskeys] = await Promise.all([
storage.getRevisionDate(userId),
storage.getAccountPasskeyCredentialsByUserId(userId),
]);
const accountPasskeyCacheTag = accountPasskeys
.map((credential) => [
credential.id,
credential.updatedAt,
credential.supportsPrf ? '1' : '0',
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
].join(':'))
.join(',');
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
}
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
const profile: ProfileResponse = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
@@ -111,17 +119,22 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
ciphers: cipherResponses,
domains: excludeDomains
? null
: {
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
},
: buildDomainsResponse(
domainSettings?.equivalentDomains || [],
domainSettings?.customEquivalentDomains || [],
domainSettings?.excludedGlobalEquivalentDomains || [],
{ omitExcludedGlobals: true }
),
policies: [],
policiesNew: [],
sends: sendResponses,
UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions,
V2UpgradeToken: null,
Object: 'userDecryption',
},
UserDecryptionOptions: userDecryptionOptions,
+29 -4
View File
@@ -1,5 +1,6 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { BackupTransferRunner } from './durable/backup-transfer-runner';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
@@ -23,21 +24,44 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
);
}
function addSearchIndexHeaders(request: Request, response: Response): Response {
const url = new URL(request.url);
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
const shouldNoIndex =
url.pathname === '/robots.txt' ||
contentType.includes('text/html');
if (!shouldNoIndex) return response;
const headers = new Headers(response.headers);
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null;
return env.ASSETS.fetch(request);
const response = await env.ASSETS.fetch(request);
return addSearchIndexHeaders(request, response);
}
async function ensureDatabaseInitialized(env: Env): Promise<void> {
@@ -68,7 +92,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
return applyCors(normalizedRequest, assetResponse, env);
}
await ensureDatabaseInitialized(env);
@@ -86,11 +110,11 @@ export default {
},
500
);
return applyCors(normalizedRequest, resp);
return applyCors(normalizedRequest, resp, 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> {
@@ -107,3 +131,4 @@ export default {
};
export { NotificationsHub };
export { BackupTransferRunner };
+3 -3
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
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);
}
@@ -50,11 +50,11 @@ export async function handleAdminBackupRoute(
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);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
+44 -2
View File
@@ -4,11 +4,32 @@ import {
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminDeleteInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
handleAdminListAuditLogs,
handleAdminGetAuditLogSettings,
handleAdminUpdateAuditLogSettings,
handleAdminClearAuditLogs,
} from './handlers/admin';
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(
request: Request,
@@ -17,10 +38,31 @@ export async function handleAdminRoute(
path: string,
method: string
): Promise<Response | null> {
if (!isKnownAdminPath(path)) {
return null;
}
if (!isActiveAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'GET') {
return handleAdminListAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'DELETE') {
return handleAdminClearAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs/settings') {
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
return null;
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse;
@@ -34,7 +76,7 @@ export async function handleAdminRoute(
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
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);
+175 -13
View File
@@ -1,8 +1,9 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
handleGetKeys,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
@@ -11,6 +12,16 @@ import {
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleGetTwoFactorYubiKey,
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleGetDeviceVerificationSettings,
handlePutDeviceVerificationSettings,
handleDisableTwoFactorProvider,
handleGetApiKey,
handleRotateApiKey,
} from './handlers/accounts';
@@ -65,6 +76,26 @@ import {
} from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices';
import { handleAdminRoute } from './router-admin';
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import {
handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential,
handleDeleteTwoFactorWebAuthn,
handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions,
handleGetTwoFactorWebAuthn,
handleGetTwoFactorWebAuthnChallenge,
handlePutTwoFactorWebAuthn,
handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys';
import {
handleCreateAdminAuthRequest,
handleGetAuthRequest,
handleListAuthRequests,
handleListPendingAuthRequests,
handleUpdateAuthRequest,
} from './handlers/auth-requests';
export async function handleAuthenticatedRoute(
request: Request,
@@ -86,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 (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
@@ -96,8 +161,10 @@ export async function handleAuthenticatedRoute(
return handleChangePassword(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
if (path === '/api/accounts/keys') {
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') {
@@ -110,6 +177,66 @@ export async function handleAuthenticatedRoute(
return handleGetTotpRecoveryCode(request, env, userId);
}
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if ((path === '/api/two-factor/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') {
return handleGetRevisionDate(request, env, userId);
}
@@ -130,6 +257,28 @@ export async function handleAuthenticatedRoute(
return handleRotateApiKey(request, env, userId);
}
if (path === '/api/webauthn' || path === '/webauthn') {
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
}
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
}
const accountPasskeyDeleteMatch =
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
if (accountPasskeyDeleteMatch && method === 'POST') {
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
@@ -231,8 +380,26 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
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/')) {
@@ -297,14 +464,9 @@ export async function handleAuthenticatedRoute(
return null;
}
if (path === '/api/settings/domains') {
if (method === 'GET' || method === 'PUT' || method === 'POST') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
if (path === '/api/settings/domains' || path === '/settings/domains') {
if (method === 'GET') return handleGetDomains(env, userId);
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
return null;
}
+32 -14
View File
@@ -11,14 +11,21 @@ import {
handleDeactivateDevice,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleTrustDevicePermanently,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
handleRegisterDevice,
handleReportLostTrust,
} from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute(
request: Request,
env: Env,
@@ -26,25 +33,36 @@ export async function handleAuthenticatedDeviceRoute(
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/devices') {
if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'POST') return handleRegisterDevice(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
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 === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null;
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
@@ -54,59 +72,59 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
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') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
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')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
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);
}
if (path === '/api/devices/untrust' && method === 'POST') {
if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
+217 -31
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import {
handleAccessSend,
handleAccessSendFile,
@@ -8,29 +7,50 @@ import {
handleDownloadSendFile,
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import {
handleDigitalAssetLinkCheck,
handleFillAssistForms,
handleFillAssistManifest,
} from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import {
handleRegister,
handleGetPasswordHint,
handleRecoverTwoFactor,
} from './handlers/accounts';
import {
handleCreateAuthRequest,
handleGetAuthRequestResponse,
} from './handlers/auth-requests';
import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments';
import {
handleAnonymousNotificationsHub,
handleNotificationsHub,
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { jsonResponse } from './utils/response';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
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 {
defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
websiteIconsEnabled: boolean;
}
function isWebsiteIconProxyEnabled(env: Env): boolean {
return true;
}
function isSameOriginWriteRequest(request: Request): boolean {
@@ -88,6 +108,7 @@ function buildIconServiceCsp(origin: string): string {
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
@@ -100,7 +121,7 @@ function buildConfigResponse(origin: string) {
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
@@ -113,11 +134,14 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': true,
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
@@ -142,6 +166,18 @@ function normalizeIconHost(rawHost: string): string | null {
}
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
type IconSource = {
url: string;
rejectImage?: {
byteLength: number;
sha256: string;
};
headers?: HeadersInit;
};
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController();
@@ -161,65 +197,144 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }):
}
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
},
});
}
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
if (!isWebsiteIconProxyEnabled(env)) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
const upstreamSources: IconSource[] = [
{
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
headers: requestHeaders,
},
{
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
headers: requestHeaders,
rejectImage: {
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
},
{
url: `https://favicon.im/${encodedHost}`,
headers: requestHeaders,
},
{
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
headers: requestHeaders,
},
];
try {
for (const source of upstreamSources) {
try {
const resp = await fetchIconSource(source);
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
if (!isSafeWebsiteIconContentType(contentType)) continue;
return new Response(resp.body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (!bytes) continue;
if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
}
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
continue;
}
}
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason =
!secret
? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
const storage = new StorageService(env.DB);
const userCount = await storage.getUserCount();
return {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
};
}
@@ -243,13 +358,34 @@ export async function handlePublicRoute(
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(buildWebBootstrapResponse(env));
return jsonResponse(await buildWebBootstrapResponse(env));
}
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);
if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
@@ -299,6 +435,19 @@ export async function handlePublicRoute(
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') {
return handleToken(request, env);
}
@@ -332,10 +481,41 @@ export async function handlePublicRoute(
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAccountPasskeyAssertionOptions(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
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') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
@@ -352,7 +532,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
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') {
@@ -380,5 +560,11 @@ export async function handlePublicRoute(
if (path === '/notifications/hub' && method === 'GET') {
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;
}
+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 { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,25 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated';
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();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
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 {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -26,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
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> {
const url = new URL(request.url);
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 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;
return new Response(
@@ -70,29 +148,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
if (method === 'OPTIONS') {
return handleCors(request);
return handleCors(request, env);
}
try {
const isLargeUploadPath =
/^\/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';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
if (bodyLimitResult instanceof Response) {
return bodyLimitResult;
}
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);
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 authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader);
+210
View File
@@ -0,0 +1,210 @@
import type { Env } from '../types';
import { generateUUID } from '../utils/uuid';
import { StorageService } from './storage';
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditEventInput {
actorUserId?: string | null;
action: string;
category: AuditLogCategory;
level?: AuditLogLevel;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
const MAX_METADATA_BYTES = 2048;
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
const AUDIT_CLEANUP_PROBABILITY = 0.02;
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
retentionDays: 90,
maxEntries: null,
};
let lastAuditCleanupAt = 0;
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
const ALLOWED_METADATA_KEYS = new Set([
'method',
'path',
'ip',
'userAgent',
'email',
'targetEmail',
'grantType',
'webSession',
'deviceIdentifier',
'deviceType',
'reason',
'status',
'verifyDevices',
'changed',
'removed',
'updated',
'deleted',
'removedTrusted',
'removedSessions',
'removedDevices',
'requested',
'count',
'requestedCount',
'type',
'folderId',
'cipherId',
'size',
'users',
'ciphers',
'attachments',
'skippedAttachments',
'skippedReason',
'replaceExisting',
'provider',
'prfStatus',
'fileName',
'fileBytes',
'bytes',
'compressedBytes',
'includesAttachments',
'destinationName',
'destinationId',
'destinationType',
'destinationCount',
'scheduledDestinationCount',
'retentionDays',
'maxEntries',
'remotePath',
'trigger',
'prunedFileCount',
'pruneError',
'uploadVerificationAttempts',
'error',
'expiresInHours',
'checksumMismatchAccepted',
]);
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
const parsed = Math.floor(Number(value));
return allowed.includes(parsed) ? parsed : null;
}
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
if (retentionDays) return { retentionDays, maxEntries: null };
if (maxEntries) return { retentionDays: null, maxEntries };
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
return { retentionDays: null, maxEntries: null };
}
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
return { retentionDays: null, maxEntries: null };
}
return {
...DEFAULT_AUDIT_LOG_SETTINGS,
};
}
export function auditRequestMetadata(request: Request): Record<string, unknown> {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
userAgent: request.headers.get('User-Agent') || null,
};
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
if (value === undefined || value === null || value === '') continue;
if (SENSITIVE_KEY_RE.test(key)) continue;
if (Array.isArray(value)) {
clean[key] = value.length;
continue;
}
if (typeof value === 'object') continue;
clean[key] = value;
}
return clean;
}
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
try {
return normalizeAuditLogSettings(JSON.parse(raw));
} catch {
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
}
}
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
const normalized = normalizeAuditLogSettings(settings);
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
await applyAuditLogRetention(storage, normalized);
return normalized;
}
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
const current = settings || await getAuditLogSettings(storage);
if (current.retentionDays) {
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
await storage.pruneAuditLogs(before);
}
if (current.maxEntries) {
await storage.pruneAuditLogsToMax(current.maxEntries);
}
}
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
const now = Date.now();
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
lastAuditCleanupAt = now;
await applyAuditLogRetention(storage);
}
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
const metadata = sanitizeMetadata(event.metadata || {});
let metadataJson = JSON.stringify(metadata);
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
metadataJson = JSON.stringify({ truncated: true });
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: event.actorUserId ?? null,
action: event.action,
category: event.category,
level: event.level || 'info',
targetType: event.targetType ?? null,
targetId: event.targetId ?? null,
metadata: metadataJson,
createdAt: new Date().toISOString(),
});
await maybePruneAuditLogs(storage);
}
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
try {
await insertAuditEvent(storage, event);
} catch (error) {
console.error('audit log write failed', error);
}
}
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
await writeAuditEvent(new StorageService(env.DB), event);
}
+99 -22
View File
@@ -1,11 +1,13 @@
import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
import { StorageService } from './storage';
// Server-side iterations for second-layer hashing.
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry {
@@ -23,6 +25,23 @@ export interface VerifiedAccessContext {
user: User;
}
export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired'
| 'user_missing'
| 'user_inactive'
| 'security_stamp_mismatch'
| 'device_missing'
| 'device_session_mismatch';
export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
userId?: string | null;
deviceIdentifier?: string | null;
};
export class AuthService {
private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
@@ -32,6 +51,25 @@ export class AuthService {
this.storage = new StorageService(env.DB);
}
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId);
if (!cached) return undefined;
@@ -98,7 +136,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
@@ -116,20 +154,17 @@ export class AuthService {
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary);
return SERVER_HASH_PREFIX + btoa(binary);
}
// Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$".
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
if (email && storedHash.startsWith('$s$')) {
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
return this.constantTimeEquals(inputHash, storedHash);
}
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
}
// Legacy path: direct constant-time comparison of raw client hashes.
return this.constantTimeEquals(inputHash, storedHash);
}
private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
@@ -157,9 +192,23 @@ export class AuthService {
}
// Generate refresh token
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
async generateRefreshToken(
user: User,
device?: { identifier: string; sessionStamp: string } | null,
clientType: string = 'other'
): Promise<string> {
const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
const now = Date.now();
await this.storage.saveRefreshToken(
token,
user.id,
now + getRefreshTokenSlidingTtlMs(clientType),
device?.identifier ?? null,
device?.sessionStamp ?? null,
user.securityStamp,
clientType,
now + LIMITS.auth.refreshTokenAbsoluteTtlMs
);
return token;
}
@@ -204,17 +253,26 @@ export class AuthService {
}
// Refresh access token
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return null;
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(record.userId);
if (!user) return null;
if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (record.securityStamp && record.securityStamp !== user.securityStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'security_stamp_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.securityStamp) {
await this.storage.bindRefreshTokenSecurityStamp(refreshToken, user.securityStamp);
}
let device: { identifier: string; sessionStamp: string } | null = null;
@@ -222,16 +280,35 @@ export class AuthService {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return null;
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.deviceSessionStamp) {
await this.storage.bindRefreshTokenDeviceStamp(refreshToken, boundDevice.sessionStamp);
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
}
const now = Date.now();
const expiresAt = Math.min(
now + getRefreshTokenSlidingTtlMs(record.clientType),
record.absoluteExpiresAt || (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
const extended = await this.storage.extendRefreshTokenExpiry(refreshToken, expiresAt, now);
if (!extended) {
return { ok: false, reason: 'token_not_found_or_expired', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user, device);
return { accessToken, user, device };
return { ok: true, accessToken, user, device, expiresAt };
}
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const result = await this.refreshAccessTokenDetailed(refreshToken);
return result.ok ? result : null;
}
}
+160 -9
View File
@@ -1,23 +1,38 @@
import { zipSync, unzipSync } from 'fflate';
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import {
getAttachmentObjectKey,
getBlobStorageKind,
} from './blob-store';
// CONTRACT:
// This file defines the exported instance-backup archive shape. Keep it in lock
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
//
// WHEN CHANGING THIS:
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json.
// - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance.
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Worker-side backup export must stay well below Cloudflare CPU limits.
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
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_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
export interface BackupManifest {
formatVersion: 1;
@@ -48,10 +63,13 @@ export interface BackupPayload {
db: {
config: SqlRow[];
users: SqlRow[];
domain_settings: SqlRow[];
user_revisions: SqlRow[];
folders: SqlRow[];
ciphers: SqlRow[];
attachments: SqlRow[];
webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
};
}
@@ -89,6 +107,23 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
return (result.results || []).map((row) => ({ ...row }));
}
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
const sanitized: SqlRow[] = [];
for (const row of rows) {
const key = String(row.key || '').trim();
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
continue;
}
sanitized.push({ ...row });
}
return sanitized;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
@@ -152,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[] {
const entries: string[] = [];
for (const row of db.attachments) {
@@ -189,8 +279,11 @@ export function parseBackupArchive(
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
try {
zipped = unzipSync(bytes);
} catch {
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
} catch (error) {
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
throw error;
}
throw new Error('Invalid backup archive');
}
@@ -201,6 +294,7 @@ export function parseBackupArchive(
let totalExtractedBytes = 0;
for (const entry of entryNames) {
validateBackupEntryName(entry);
const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
@@ -264,9 +358,12 @@ export function validateBackupPayloadContents(
const configRows = ensureRowArray(payload.db.config, 'config');
const userRows = ensureRowArray(payload.db.users, 'users');
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
const folderRows = ensureRowArray(payload.db.folders, 'folders');
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -294,6 +391,18 @@ export function validateBackupPayloadContents(
}
}
const domainSettingUserIds = new Set<string>();
for (const row of domainSettingsRows) {
const userId = String(row.user_id || '').trim();
if (!userId || !userIds.has(userId)) {
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
}
if (domainSettingUserIds.has(userId)) {
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
}
domainSettingUserIds.add(userId);
}
const folderIds = new Set<string>();
for (const row of folderRows) {
const id = String(row.id || '').trim();
@@ -319,7 +428,7 @@ export function validateBackupPayloadContents(
for (const row of attachmentRows) {
const id = String(row.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');
}
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
@@ -327,6 +436,38 @@ export function validateBackupPayloadContents(
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
}
}
const accountPasskeyIds = new Set<string>();
const accountPasskeyCredentialIds = new Set<string>();
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
throw new Error('Backup archive contains an invalid account passkey row');
}
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId);
}
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
}
export async function buildBackupArchive(
@@ -345,14 +486,18 @@ export async function buildBackupArchive(
includeAttachments,
});
const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, 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 exportedAttachmentRows = includeAttachments ? attachmentRows : [];
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
const cipherId = String(row.cipher_id || '').trim();
@@ -371,12 +516,15 @@ export async function buildBackupArchive(
appVersion: APP_VERSION,
storageKind: getBlobStorageKind(env),
tableCounts: {
config: configRows.length,
config: exportedConfigRows.length,
users: userRows.length,
domain_settings: domainSettingsRows.length,
user_revisions: revisionRows.length,
folders: folderRows.length,
ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
},
includes: {
attachments: includeAttachments,
@@ -392,12 +540,15 @@ export async function buildBackupArchive(
const files: Record<string, Uint8Array> = {
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
'db.json': encoder.encode(JSON.stringify({
config: configRows,
config: exportedConfigRows,
users: userRows,
domain_settings: domainSettingsRows,
user_revisions: revisionRows,
folders: folderRows,
ciphers: cipherRows,
attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)),
};
+302 -36
View File
@@ -16,6 +16,7 @@ import {
type BackupRuntimeState,
type BackupScheduleConfig,
type BackupSettings,
type S3BackupAddressingStyle,
type S3BackupDestination,
type WebDavBackupDestination,
createBackupRandomId,
@@ -25,7 +26,9 @@ import {
} from '../../shared/backup-schema';
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 REDACTED_BACKUP_SECRET = '********';
const MAX_BACKUP_DESTINATIONS = 24;
export type {
@@ -35,6 +38,7 @@ export type {
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination,
WebDavBackupDestination,
} from '../../shared/backup-schema';
@@ -64,6 +68,163 @@ function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function normalizeHostnameForPolicy(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function parseIpv4Address(hostname: string): number[] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return -1;
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
});
return octets.every((value) => value >= 0) ? octets : null;
}
function isBlockedIpv4Address(octets: number[]): boolean {
const [a, b, c] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && (b === 0 || b === 168)) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224
);
}
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets);
}
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return (
firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 ||
hextets.join(':').startsWith('2001:0db8:')
);
}
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
const normalized = normalizeHostnameForPolicy(hostname);
if (!normalized) throw new Error(`${label} host is required`);
if (
normalized === 'localhost' ||
normalized === 'localhost.localdomain' ||
normalized.endsWith('.localhost.localdomain') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.home.arpa') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.lan') ||
normalized === 'metadata.google.internal' ||
normalized === 'localtest.me' ||
normalized.endsWith('.localtest.me') ||
normalized === 'lvh.me' ||
normalized.endsWith('.lvh.me') ||
normalized === 'vcap.me' ||
normalized.endsWith('.vcap.me') ||
normalized === 'nip.io' ||
normalized.endsWith('.nip.io') ||
normalized === 'sslip.io' ||
normalized.endsWith('.sslip.io') ||
normalized === 'xip.io' ||
normalized.endsWith('.xip.io')
) {
throw new Error(`${label} host is not allowed`);
}
const ipv4 = parseIpv4Address(normalized);
if (ipv4 && isBlockedIpv4Address(ipv4)) {
throw new Error(`${label} host is not allowed`);
}
if (isBlockedIpv6Address(normalized)) {
throw new Error(`${label} host is not allowed`);
}
}
export function normalizeBackupEndpointUrl(value: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${label} must start with http:// or https://`);
}
if (parsed.username || parsed.password) {
throw new Error(`${label} must not include credentials`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${label} must not include query or fragment`);
}
assertBackupEndpointHostAllowed(parsed.hostname, label);
return parsed.toString().replace(/\/+$/, '');
}
function assertValidTimeZone(timezone: string): string {
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
@@ -109,6 +270,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket);
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
const addressingStyle: S3BackupAddressingStyle =
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto';
@@ -116,7 +280,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
if (!allowIncomplete || endpoint) {
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 (!bucket) throw new Error('S3 bucket is required');
@@ -129,8 +293,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket,
addressingStyle,
region,
accessKeyId,
secretAccessKey,
@@ -147,7 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
if (!allowIncomplete || baseUrl) {
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 (!username) throw new Error('WebDAV username is required');
@@ -157,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
}
return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
username,
password,
remotePath,
@@ -173,6 +338,32 @@ function normalizeDestination(
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 {
const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => {
@@ -243,7 +434,11 @@ function normalizeDestinationRecord(
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 {
id,
@@ -318,6 +513,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
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 {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
}
@@ -381,27 +617,55 @@ export function normalizeBackupSettingsInput(
}
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> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
return mergeRuntime(settings);
}
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
return mergeRuntime(settings);
}
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone);
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch {
throw new Error('Backup settings need administrator reactivation after restore');
}
@@ -409,15 +673,29 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
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> {
@@ -442,12 +720,6 @@ export async function normalizeImportedBackupSettingsValue(
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
@@ -455,12 +727,6 @@ export async function normalizeImportedBackupSettingsValue(
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
}
@@ -609,9 +875,9 @@ export function hasBackupSlotBetween(
const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
? lastAttemptAt.getTime()
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs);
@@ -633,7 +899,7 @@ export function hasBackupSlotBetween(
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue;
if (lastSuccessMs >= slotStartMs) continue;
return true;
}
}
@@ -650,9 +916,9 @@ export function isBackupDueNow(
): boolean {
if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
? lastAttemptAt.getTime()
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay(
@@ -665,7 +931,7 @@ export function isBackupDueNow(
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false;
if (lastSuccessMs >= slotStartMs) return false;
return true;
}
return false;
+99 -5
View File
@@ -4,15 +4,29 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
// CONTRACT:
// Restore is intentionally whitelist-based. Old backups may contain retired
// fields, but only the columns listed here are imported. Keep this file in sync
// with src/services/backup-archive.ts whenever backup contents change.
//
// WHEN CHANGING THIS:
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
// shadow-table count validation, insert column lists, and frontend import
// count types together.
// - Do not import users.api_key, even if an older backup contains it.
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'domain_settings'
| 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials'
| 'folders'
| 'ciphers'
| 'attachments';
@@ -20,7 +34,10 @@ type BackupTableName =
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'domain_settings',
'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials',
'folders',
'ciphers',
'attachments',
@@ -35,7 +52,10 @@ export interface BackupImportResultBody {
imported: {
config: number;
users: number;
domainSettings: number;
userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number;
folders: number;
ciphers: number;
attachments: number;
@@ -155,6 +175,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM attachments',
'DELETE FROM ciphers',
'DELETE FROM folders',
'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings',
'DELETE FROM user_revisions',
'DELETE FROM users',
'DELETE FROM config',
@@ -231,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
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[] {
let replaced = false;
const nextRows = rows.map((row) => {
@@ -274,9 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0,
})),
domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []),
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 || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
@@ -436,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
const lookup = new Map<string, BackupManifestAttachmentBlob>();
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(
@@ -594,7 +639,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements(
db,
tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
payload.users || []
)
);
@@ -603,6 +648,37 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
tableName('user_revisions'),
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
);
await runInsertBatch(
db,
tableName('domain_settings'),
buildInsertStatements(
db,
tableName('domain_settings'),
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
payload.domain_settings || [],
true
)
);
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch(
db,
tableName('webauthn_credentials'),
buildInsertStatements(
db,
tableName('webauthn_credentials'),
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || []
)
);
await runInsertBatch(
db,
tableName('folders'),
@@ -669,7 +745,10 @@ export async function importBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
@@ -690,7 +769,10 @@ export async function importBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -729,7 +811,10 @@ export async function importBackupArchiveBytes(
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -804,7 +889,10 @@ export async function importRemoteBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
@@ -825,7 +913,10 @@ export async function importRemoteBackupArchiveBytes(
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
@@ -870,7 +961,10 @@ export async function importRemoteBackupArchiveBytes(
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
+30 -3
View File
@@ -1,5 +1,17 @@
import type { Env, User } from '../types';
// CONTRACT:
// Backup settings contain provider credentials. They are stored as a v2 envelope:
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
// server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
// active admin public keys so settings can be repaired after restore/migration.
// Historical/imported databases may not have usable admin public keys; in that
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
//
// New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider
// secrets as plain config JSON.
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
const RUNTIME_INFO = 'runtime';
const PORTABLE_ALGORITHM = 'RSA-OAEP';
@@ -155,6 +167,20 @@ export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsE
}
}
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) return null;
return JSON.stringify({
version: 2,
portableOnly: true,
runtime: {
iv: '',
ciphertext: '',
},
portable: envelope.portable,
});
}
export async function encryptBackupSettingsEnvelope(
plaintext: string,
env: Env,
@@ -162,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
): Promise<string> {
const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
@@ -181,6 +204,7 @@ export async function encryptBackupSettingsEnvelope(
const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) {
try {
const publicKey = await importPortablePublicKey(user.publicKey!);
const wrappedKey = new Uint8Array(
await crypto.subtle.encrypt(
@@ -193,6 +217,9 @@ export async function encryptBackupSettingsEnvelope(
userId: user.id,
wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
}
const envelope: BackupSettingsEnvelopeV2 = {
+75 -16
View File
@@ -3,6 +3,7 @@ import {
BackupDestinationType,
S3BackupDestination,
WebDavBackupDestination,
normalizeBackupEndpointUrl,
} from './backup-config';
export interface BackupUploadResult {
@@ -33,6 +34,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array;
}
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions {
contentType?: string;
}
@@ -208,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination;
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.password || '')) throw new Error('WebDAV password is required');
return;
@@ -216,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
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.accessKeyId || '').trim()) throw new Error('S3 access 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,
},
});
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}`);
}
}
@@ -268,7 +276,7 @@ async function ensureWebDavDirectoryCached(
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
@@ -433,6 +441,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
}
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 remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,15 +453,40 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
Authorization: authHeader,
},
});
if (response.status === 404) return false;
if (response.status === 404) return null;
if (!response.ok) {
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 {
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 {
@@ -482,7 +519,7 @@ async function signedS3Request(
config.region || 'auto'
);
return fetch(url.toString(), {
return fetch(url, {
method,
headers: {
Authorization: authorization,
@@ -501,7 +538,7 @@ async function putToS3(
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) {
@@ -521,10 +558,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
do {
const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
@@ -532,8 +575,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
}
const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
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>();
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');
}
const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`);
@@ -610,7 +654,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`);
@@ -618,14 +662,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
}
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 url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false;
if (response.status === 404) return null;
if (!response.ok) {
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 {
@@ -637,6 +691,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
}
export interface RemoteBackupTransferSession {
@@ -647,6 +702,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
}
function resolveConfiguredDestinationAdapter(
@@ -664,6 +720,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
};
}
if (destination.type === 's3') {
@@ -676,6 +733,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(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),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
};
}
+222
View File
@@ -0,0 +1,222 @@
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
// CONTRACT:
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
// the full custom rule list and the derived active equivalent-domain groups:
// - custom_equivalent_domains: UI/client rules with id + excluded state.
// - equivalent_domains: active groups derived from non-excluded custom rules.
// - excluded_global_equivalent_domains: disabled global rule type ids.
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
// duplicates without a migration and compatibility plan.
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
Type?: unknown;
Domains?: unknown;
Excluded?: unknown;
};
function normalizeDomain(value: unknown): string {
return normalizeEquivalentDomain(value);
}
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
const type = Number(entry.type ?? entry.Type);
if (!Number.isInteger(type)) return null;
const rawDomains = entry.domains ?? entry.Domains;
if (!Array.isArray(rawDomains)) return null;
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
if (domains.length < 2) return null;
return {
type,
domains,
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
};
}
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
if (!Array.isArray(input)) return [];
const seen = new Set<number>();
const out: GlobalEquivalentDomain[] = [];
for (const entry of input) {
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
if (!normalized || seen.has(normalized.type)) continue;
seen.add(normalized.type);
out.push(normalized);
}
return out;
}
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
export const globalDomains: readonly GlobalEquivalentDomain[] = [
...bitwardenGlobalDomains,
...customGlobalDomains,
];
export function normalizeEquivalentDomains(input: unknown): string[][] {
if (!Array.isArray(input)) return [];
const groups: string[][] = [];
const seenGroups = new Set<string>();
for (const group of input) {
if (!Array.isArray(group)) continue;
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
if (domains.length < 2) continue;
const key = domains.slice().sort().join('\n');
if (seenGroups.has(key)) continue;
seenGroups.add(key);
groups.push(domains);
}
return groups;
}
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
const parent = new Map<string, string>();
function find(domain: string): string {
const current = parent.get(domain);
if (!current) {
parent.set(domain, domain);
return domain;
}
if (current === domain) return domain;
const root = find(current);
parent.set(domain, root);
return root;
}
function union(a: string, b: string): void {
const rootA = find(a);
const rootB = find(b);
if (rootA !== rootB) parent.set(rootB, rootA);
}
for (const group of normalizeEquivalentDomains(input)) {
if (group.length < 2) continue;
const [first, ...rest] = group;
find(first);
for (const domain of rest) union(first, domain);
}
const components = new Map<string, string[]>();
for (const domain of parent.keys()) {
const root = find(domain);
const group = components.get(root) || [];
group.push(domain);
components.set(root, group);
}
return Array.from(components.values())
.map((group) => group.sort())
.filter((group) => group.length >= 2)
.sort((a, b) => a[0].localeCompare(b[0]));
}
export function expandCustomEquivalentDomainsWithGlobals(
customGroups: string[][],
activeGlobalGroups: string[][]
): string[][] {
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
if (!normalizedCustomGroups.length) return [];
const customDomains = new Set(normalizedCustomGroups.flat());
return mergeEquivalentDomainGroups([
...activeGlobalGroups,
...normalizedCustomGroups,
]).filter((group) => group.some((domain) => customDomains.has(domain)));
}
function createCustomDomainId(domains: string[], index: number): string {
return `custom:${domains.slice().sort().join('|')}:${index}`;
}
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
if (!Array.isArray(input)) return [];
const rules: CustomEquivalentDomain[] = [];
const seenGroups = new Set<string>();
for (const [index, item] of input.entries()) {
const record = Array.isArray(item)
? { domains: item, excluded: false, id: '' }
: item && typeof item === 'object'
? item as Record<string, unknown>
: null;
if (!record) continue;
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
if (!domains) continue;
const key = domains.slice().sort().join('\n');
if (seenGroups.has(key)) continue;
seenGroups.add(key);
const rawId = String(record.id ?? record.Id ?? '').trim();
rules.push({
id: rawId || createCustomDomainId(domains, index),
domains,
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
});
}
return rules;
}
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
return mergeEquivalentDomainGroups(rules
.filter((rule) => !rule.excluded)
.map((rule) => rule.domains));
}
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
if (!Array.isArray(input)) return [];
const validTypes = new Set(globalDomains.map((entry) => entry.type));
const seen = new Set<number>();
const out: number[] = [];
for (const item of input) {
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
const excluded = typeof item === 'object' && item !== null
? Boolean((item as Record<string, unknown>).excluded)
: true;
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
seen.add(type);
out.push(type);
}
return out;
}
export function buildDomainsResponse(
equivalentDomains: string[][],
customEquivalentDomains: CustomEquivalentDomain[],
excludedGlobalEquivalentDomains: number[],
options: { omitExcludedGlobals?: boolean } = {}
): DomainRulesResponse {
const excluded = new Set(excludedGlobalEquivalentDomains);
const activeGlobalDomainGroups = globalDomains
.filter((entry) => !excluded.has(entry.type))
.map((entry) => entry.domains);
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
equivalentDomains,
activeGlobalDomainGroups
);
const globals = globalDomains
.map((entry) => ({
type: entry.type,
domains: entry.domains,
excluded: excluded.has(entry.type),
}))
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
return {
equivalentDomains: mergedEquivalentDomains,
customEquivalentDomains,
globalEquivalentDomains: globals,
object: 'domains',
};
}
+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.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - 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 = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
@@ -12,11 +13,14 @@ const CONFIG = {
export class RateLimitService {
private static loginIpTableReady = false;
private static strictBudgetTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastStrictBudgetCleanupAt = 0;
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_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
constructor(private db: D1Database) {}
@@ -58,6 +62,35 @@ export class RateLimitService {
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<{
allowed: boolean;
remainingAttempts: number;
@@ -174,6 +207,59 @@ export class RateLimitService {
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.
// Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised
@@ -0,0 +1,338 @@
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
let accountPasskeySchemaReady = false;
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
{ name: 'id', sql: 'id TEXT' },
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
{ name: 'type', sql: 'type TEXT' },
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
{ name: 'transports', sql: 'transports TEXT' },
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
] as const;
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
'challenge_hash',
'scope',
'user_id',
'expires_at',
'used_at',
'created_at',
] as const;
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
}
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
if (accountPasskeySchemaReady) return;
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
)
.run();
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
if (!credentialColumns.has(column.name)) {
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
}
}
credentialColumns = await tableColumns(db, 'webauthn_credentials');
if (!credentialColumns.has('credential_id')) {
throw new Error('webauthn_credentials schema is missing credential_id');
}
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
)
.run();
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
if (!challengeSchemaComplete) {
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
await db
.prepare(
'CREATE TABLE webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
)
.run();
}
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
accountPasskeySchemaReady = true;
}
function parseTransports(value: string | null): string[] | null {
if (!value) return null;
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return null;
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
} catch {
return null;
}
}
function mapCredentialRow(row: {
id: string;
user_id: string;
purpose?: string | null;
name: string;
public_key: string;
credential_id: string;
counter: number;
type: string | null;
aa_guid: string | null;
transports: string | null;
encrypted_user_key: string | null;
encrypted_public_key: string | null;
encrypted_private_key: string | null;
supports_prf: number;
created_at: string;
updated_at: string;
}): AccountPasskeyCredential {
return {
id: row.id,
userId: row.user_id,
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
name: row.name,
publicKey: row.public_key,
credentialId: row.credential_id,
counter: Number(row.counter || 0),
type: row.type ?? null,
aaGuid: row.aa_guid ?? null,
transports: parseTransports(row.transports),
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
supportsPrf: !!row.supports_prf,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapChallengeRow(row: {
challenge_hash: string;
scope: AccountPasskeyChallengeScope;
user_id: string | null;
expires_at: number;
used_at: number | null;
created_at: number;
}): AccountPasskeyChallenge {
return {
challengeHash: row.challenge_hash,
scope: row.scope,
userId: row.user_id ?? null,
expiresAt: Number(row.expires_at || 0),
usedAt: row.used_at == null ? null : Number(row.used_at),
createdAt: Number(row.created_at || 0),
};
}
export async function saveAccountPasskeyCredential(
db: D1Database,
safeBind: SafeBindFn,
credential: AccountPasskeyCredential
): Promise<void> {
await ensureAccountPasskeySchema(db);
await safeBind(
db.prepare(
'INSERT INTO webauthn_credentials(' +
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
),
credential.id,
credential.userId,
credential.purpose,
credential.name,
credential.publicKey,
credential.credentialId,
credential.counter,
credential.type,
credential.aaGuid,
credential.transports ? JSON.stringify(credential.transports) : null,
credential.encryptedUserKey,
credential.encryptedPublicKey,
credential.encryptedPrivateKey,
credential.supportsPrf ? 1 : 0,
credential.createdAt,
credential.updatedAt
).run();
}
export async function listAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
.bind(userId, purpose)
.all<any>();
return (rows.results || []).map(mapCredentialRow);
}
export async function getAccountPasskeyCredentialById(
db: D1Database,
userId: string,
id: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
.bind(userId, id)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function getAccountPasskeyCredentialByCredentialId(
db: D1Database,
credentialId: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
.bind(credentialId)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function countAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
.bind(userId, purpose)
.first<{ count: number }>();
return Number(row?.count || 0);
}
export async function updateAccountPasskeyCounter(
db: D1Database,
userId: string,
credentialId: string,
counter: number,
updatedAt: string
): Promise<void> {
await ensureAccountPasskeySchema(db);
await db
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
.bind(counter, updatedAt, userId, credentialId)
.run();
}
export async function updateAccountPasskeyEncryption(
db: D1Database,
userId: string,
credentialId: string,
encryptedUserKey: string,
encryptedPublicKey: string,
encryptedPrivateKey: string,
updatedAt: string
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare(
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
)
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run();
return Number(result.meta.changes || 0) > 0;
}
export async function deleteAccountPasskeyCredential(
db: D1Database,
userId: string,
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
.bind(userId, id, purpose)
.run();
return Number(result.meta.changes || 0) > 0;
}
export async function saveAccountPasskeyChallenge(
db: D1Database,
challenge: AccountPasskeyChallenge
): Promise<void> {
await ensureAccountPasskeySchema(db);
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
await db
.prepare(
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
)
.bind(
challenge.challengeHash,
challenge.scope,
challenge.userId,
challenge.expiresAt,
challenge.usedAt,
challenge.createdAt
)
.run();
}
export async function consumeAccountPasskeyChallenge(
db: D1Database,
challengeHash: string,
scope: AccountPasskeyChallengeScope,
userId: string | null,
nowMs: number
): Promise<AccountPasskeyChallenge | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
.bind(challengeHash, scope)
.first<any>();
if (!row) return null;
const challenge = mapChallengeRow(row);
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
if (userId !== null && challenge.userId !== userId) return null;
if (userId === null && challenge.userId !== null) return null;
const result = await db
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
.bind(nowMs, challengeHash)
.run();
if (Number(result.meta.changes || 0) <= 0) return null;
return { ...challenge, usedAt: nowMs };
}
+157 -6
View File
@@ -1,5 +1,72 @@
import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db
.prepare(
@@ -50,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
}
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.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();
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 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)
.run();
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> {
const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0);
@@ -77,8 +176,60 @@ export async function deleteAllInvites(db: D1Database): Promise<number> {
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+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> {
const stmt = db.prepare(
'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();
}
@@ -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();
}
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
await db
.prepare(
`DELETE FROM attachments
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers c
WHERE c.id = attachments.cipher_id AND c.user_id = ?
)`
)
.bind(id, userId)
.run();
}
export async function bulkDeleteAttachmentsByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
@@ -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();
}
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> {
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);
}
+22 -4
View File
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
'favorite',
'reprompt',
'key',
'attachments',
'Attachments',
'attachments2',
'Attachments2',
'createdAt',
'created_at',
'creationDate',
@@ -83,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
createdAt: row.created_at,
updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null,
deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
};
} catch {
console.error('Corrupted cipher data, id:', row.id);
@@ -103,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
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> {
const folderId = normalizeOptionalId(cipher.folderId);
const data = buildCipherData(cipher, folderId);
@@ -110,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) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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(
stmt,
@@ -240,7 +253,9 @@ export async function getCiphersPage(
limit: number,
offset: number
): 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
.prepare(
`SELECT ${selectCipherColumns()} FROM ciphers
@@ -337,7 +352,10 @@ export async function bulkArchiveCiphers(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?,
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)
.run();
+99 -5
View File
@@ -1,4 +1,5 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
import { generateUUID } from '../utils/uuid';
type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>;
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
pushUuid: row.push_uuid ?? null,
pushToken: row.push_token ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -38,13 +41,16 @@ export async function upsertDevice(
const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
await db
.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, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'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=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at'
)
@@ -57,10 +63,12 @@ export async function upsertDevice(
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
effectivePushUuid,
existingDevice?.deviceNote ?? null,
now,
now,
now
now,
''
)
.run();
}
@@ -91,6 +99,20 @@ export async function touchDeviceLastSeen(
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(
db: D1Database,
userId: string,
@@ -166,7 +188,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.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'
)
.bind(userId)
@@ -177,7 +199,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db
.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'
)
.bind(userId, deviceIdentifier)
@@ -185,6 +207,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
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> {
const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
@@ -233,6 +312,21 @@ export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userI
return Number(result.meta.changes ?? 0);
}
export async function updateTrustedTwoFactorTokensExpiryByDevice(
db: D1Database,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<number> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const result = await db
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
.bind(expiresAtMs, userId, deviceIdentifier, now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
+73
View File
@@ -0,0 +1,73 @@
import type { UserDomainSettings } from '../types';
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
// Storage adapter for the domain_settings table.
//
// CONTRACT:
// equivalent_domains is kept as the active derived groups for compatibility and
// fallback reads. custom_equivalent_domains is the full rule list that preserves
// UI/client state. Save both together through saveUserDomainSettings().
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed as T[] : fallback;
} catch {
return fallback;
}
}
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
const row = await db
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
.bind(userId)
.first<{
equivalent_domains: string | null;
custom_equivalent_domains: string | null;
excluded_global_equivalent_domains: string | null;
updated_at: string | null;
}>();
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
: [];
const customEquivalentDomains = storedCustomEquivalentDomains.length
? storedCustomEquivalentDomains
: normalizeCustomEquivalentDomains(equivalentDomains);
return {
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
updatedAt: row?.updated_at || null,
};
}
export async function saveUserDomainSettings(
db: D1Database,
userId: string,
equivalentDomains: string[][],
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
excludedGlobalEquivalentDomains: number[],
updatedAt: string
): Promise<void> {
await db
.prepare(
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
'VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id) DO UPDATE SET ' +
'equivalent_domains = excluded.equivalent_domains, ' +
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
'updated_at = excluded.updated_at'
)
.bind(
userId,
JSON.stringify(equivalentDomains),
JSON.stringify(customEquivalentDomains),
JSON.stringify(excludedGlobalEquivalentDomains),
updatedAt
)
.run();
}
+24 -5
View File
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
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> {
await db
.prepare(
'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)
.run();
@@ -44,9 +53,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
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();
}
@@ -71,9 +85,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})`
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();
await db
+92 -70
View File
@@ -11,16 +11,34 @@ export async function saveRefreshToken(
userId: string,
expiresAtMs: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token);
const now = Date.now();
await db
.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, ' +
'device_session_stamp=excluded.device_session_stamp, security_stamp=excluded.security_stamp, last_used_at=excluded.last_used_at, ' +
'absolute_expires_at=excluded.absolute_expires_at, client_type=excluded.client_type'
)
.bind(
tokenKey,
userId,
expiresAtMs,
deviceIdentifier ?? null,
deviceSessionStamp ?? null,
securityStamp ?? null,
now,
now,
absoluteExpiresAtMs ?? null,
clientType ?? null
)
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run();
}
@@ -28,13 +46,6 @@ export async function getRefreshTokenRecord(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string
): Promise<RefreshTokenRecord | null> {
@@ -42,41 +53,26 @@ export async function getRefreshTokenRecord(
await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token);
let row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
const row = await db
.prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) {
const legacyRow = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
await saveRefreshTokenRecord(
token,
legacyRow.user_id,
legacyRow.expires_at,
legacyRow.device_identifier ?? null,
legacyRow.device_session_stamp ?? null
);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return {
userId: legacyRow.user_id,
expiresAt: legacyRow.expires_at,
deviceIdentifier: legacyRow.device_identifier ?? null,
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
};
}
}
.first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
security_stamp: string | null;
created_at: number | null;
last_used_at: number | null;
absolute_expires_at: number | null;
client_type: string | null;
}>();
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
if ((row.expires_at && row.expires_at < now) || (row.absolute_expires_at && row.absolute_expires_at < now)) {
await deleteRefreshTokenRecord(token);
return null;
}
@@ -85,9 +81,62 @@ export async function getRefreshTokenRecord(
expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null,
securityStamp: row.security_stamp ?? null,
createdAt: row.created_at ?? null,
lastUsedAt: row.last_used_at ?? null,
absoluteExpiresAt: row.absolute_expires_at ?? null,
clientType: row.client_type ?? null,
};
}
export async function extendRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
requestedExpiresAtMs: number,
nowMs: number
): Promise<boolean> {
const tokenKey = await refreshTokenKey(token);
const result = await db
.prepare(
'UPDATE refresh_tokens SET ' +
'expires_at = CASE ' +
'WHEN absolute_expires_at IS NOT NULL AND absolute_expires_at < ? THEN absolute_expires_at ' +
'ELSE ? END, ' +
'last_used_at = ? ' +
'WHERE token = ? AND expires_at >= ? AND (absolute_expires_at IS NULL OR absolute_expires_at >= ?)'
)
.bind(requestedExpiresAtMs, requestedExpiresAtMs, nowMs, tokenKey, nowMs, nowMs)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function bindRefreshTokenSecurityStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
securityStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET security_stamp = ? WHERE token = ? AND (security_stamp IS NULL OR security_stamp = ?)')
.bind(securityStamp, tokenKey, '')
.run();
}
export async function bindRefreshTokenDeviceStamp(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
deviceSessionStamp: string
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare('UPDATE refresh_tokens SET device_session_stamp = ? WHERE token = ? AND (device_session_stamp IS NULL OR device_session_stamp = ?)')
.bind(deviceSessionStamp, tokenKey, '')
.run();
}
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
@@ -106,30 +155,3 @@ export async function deleteRefreshTokensByDevice(db: D1Database, userId: string
.run();
return Number(result.meta.changes ?? 0);
}
export async function constrainRefreshTokenExpiry(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
token: string,
maxExpiresAtMs: number
): Promise<void> {
const tokenKey = await refreshTokenKey(token);
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
.run();
await db
.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
)
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
.run();
}
+77 -7
View File
@@ -1,20 +1,39 @@
// IMPORTANT:
// Keep this schema list in sync with migrations/0001_init.sql.
// Any new table/column/index must be added to both places together.
// This is the runtime D1 schema bootstrap. Keep it in sync with
// migrations/0001_init.sql. Any new table/column/index must be added to both
// places together.
//
// WHEN CHANGING THIS:
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
// rerun these idempotent statements.
// - If the new table stores persistent data, update the backup export/import
// contract in src/services/backup-archive.ts and backup-import.ts.
// - Keep statements idempotent; D1 may execute them again on later requests.
const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'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, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' +
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -55,27 +74,43 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, security_stamp TEXT, created_at INTEGER, last_used_at INTEGER, absolute_expires_at INTEGER, client_type TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN security_stamp TEXT',
'ALTER TABLE refresh_tokens ADD COLUMN created_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN last_used_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN absolute_expires_at INTEGER',
'ALTER TABLE refresh_tokens ADD COLUMN client_type TEXT',
"UPDATE refresh_tokens SET security_stamp = (SELECT users.security_stamp FROM users WHERE users.id = refresh_tokens.user_id) WHERE security_stamp IS NULL OR security_stamp = ''",
"UPDATE refresh_tokens SET created_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE created_at IS NULL",
"UPDATE refresh_tokens SET last_used_at = created_at WHERE last_used_at IS NULL",
'UPDATE refresh_tokens SET absolute_expires_at = expires_at WHERE absolute_expires_at IS NULL',
'CREATE TABLE IF NOT EXISTS invites (' +
'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 (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_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'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, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -84,17 +119,52 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_user_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 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_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
"UPDATE refresh_tokens SET device_session_stamp = (SELECT devices.session_stamp FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier) WHERE device_identifier IS NOT NULL AND (device_session_stamp IS NULL OR device_session_stamp = '') AND EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier)",
"UPDATE refresh_tokens SET client_type = CASE WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type IN (0, 1)) THEN 'mobile' WHEN EXISTS (SELECT 1 FROM devices WHERE devices.user_id = refresh_tokens.user_id AND devices.device_identifier = refresh_tokens.device_identifier AND devices.type = 14) THEN 'web' ELSE 'other' END WHERE client_type IS NULL OR client_type = ''",
'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
+20 -4
View File
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
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> {
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) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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, ' +
'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(
@@ -81,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
const result = await db
.prepare(
'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();
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,
};
}
+25 -7
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS =
'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';
'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 {
return {
@@ -23,9 +23,15 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null,
yubikeyKey2: row.yubikey_key2 ?? null,
yubikeyKey3: row.yubikey_key3 ?? null,
yubikeyKey4: row.yubikey_key4 ?? null,
yubikeyKey5: row.yubikey_key5 ?? null,
yubikeyNfc: !!row.yubikey_nfc,
apiKey: row.api_key ?? null,
createdAt: row.created_at,
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> {
const email = user.email.toLowerCase();
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) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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, ' +
'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(
stmt,
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey,
user.createdAt,
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> {
const email = user.email.toLowerCase();
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) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey,
user.createdAt,
user.updatedAt
+343 -16
View File
@@ -1,5 +1,6 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
import { LIMITS } from '../config/limits';
import { ensurePushInstallationCredentials } from './push-relay';
import { ensureStorageSchema } from './storage-schema';
import {
getConfigValue as getStoredConfigValue,
@@ -18,13 +19,21 @@ import {
saveUser as saveStoredUser,
} from './storage-user-repo';
import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite,
deleteInvite as deleteStoredInvite,
deleteInvalidInvites as deleteStoredInvalidInvites,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed,
revokeInvite as revokeStoredInvite,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
} from './storage-admin-repo';
import {
bulkDeleteFolders as deleteStoredFolders,
@@ -32,6 +41,7 @@ import {
deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders,
getFolder as findStoredFolder,
getFolderForUser as findStoredFolderForUser,
getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder,
} from './storage-folder-repo';
@@ -44,6 +54,7 @@ import {
bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher,
getCipherForUser as findStoredCipherForUser,
getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher,
@@ -51,10 +62,13 @@ import {
} from './storage-cipher-repo';
import {
addAttachmentToCipher as attachStoredAttachmentToCipher,
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment,
deleteAttachmentForUser as deleteStoredAttachmentForUser,
getAttachment as findStoredAttachment,
getAttachmentForUser as findStoredAttachmentForUser,
getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId,
@@ -66,49 +80,92 @@ import {
deleteSend as deleteStoredSend,
getAllSends as listStoredSends,
getSend as findStoredSend,
getSendForUser as findStoredSendForUser,
getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount,
saveSend as saveStoredSend,
} from './storage-send-repo';
import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry,
bindRefreshTokenDeviceStamp as bindStoredRefreshTokenDeviceStamp,
bindRefreshTokenSecurityStamp as bindStoredRefreshTokenSecurityStamp,
deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
extendRefreshTokenExpiry as extendStoredRefreshTokenExpiry,
getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo';
import {
deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDevicePushToken as clearStoredDevicePushToken,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice,
getDevicePushUuid as findStoredDevicePushUuid,
getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} 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 {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import {
getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate,
} from './storage-revision-repo';
import {
getUserDomainSettings as getStoredUserDomainSettings,
saveUserDomainSettings as saveStoredUserDomainSettings,
} from './storage-domain-rules-repo';
import {
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
} from './storage-account-passkey-repo';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-04-28';
// IMPORTANT:
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-13-refresh-session-reuse';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage.
// Contract:
@@ -121,10 +178,13 @@ export class StorageService {
private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100;
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 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;
constructor(private db: D1Database) {}
@@ -139,6 +199,16 @@ export class StorageService {
return stmt.bind(...values.map(v => v === undefined ? null : v));
}
private async hasRequiredSchemaTables(): Promise<boolean> {
const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
const result = await this.db
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
.bind(...REQUIRED_SCHEMA_TABLES)
.all<{ name: string }>();
const found = new Set((result.results || []).map((row) => row.name));
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
}
private sqlChunkSize(fixedBindCount: number): number {
return Math.max(
1,
@@ -182,10 +252,14 @@ export class StorageService {
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
? !(await this.hasRequiredSchemaTables())
: true;
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
}
await ensurePushInstallationCredentials(this.db);
StorageService.schemaVerified = true;
}
@@ -258,8 +332,20 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code);
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
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> {
@@ -270,12 +356,132 @@ export class StorageService {
await createStoredAuditLog(this.db, log);
}
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
// --- Domain rules ---
async getUserDomainSettings(userId: string) {
return getStoredUserDomainSettings(this.db, userId);
}
async saveUserDomainSettings(
userId: string,
equivalentDomains: string[][],
customEquivalentDomains: CustomEquivalentDomain[],
excludedGlobalEquivalentDomains: number[]
): Promise<void> {
await saveStoredUserDomainSettings(
this.db,
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains,
new Date().toISOString()
);
await this.updateRevisionDate(userId);
}
// --- Account passkeys / WebAuthn login credentials ---
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
}
async getAccountPasskeyCredentialsByUserId(
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
}
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
}
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
}
async countAccountPasskeyCredentialsByUserId(
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
}
async updateAccountPasskeyCounter(
userId: string,
credentialId: string,
counter: number,
updatedAt: string = new Date().toISOString()
): Promise<void> {
await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt);
}
async updateAccountPasskeyEncryption(
userId: string,
credentialId: string,
encryptedUserKey: string,
encryptedPublicKey: string,
encryptedPrivateKey: string,
updatedAt: string = new Date().toISOString()
): Promise<boolean> {
return updateStoredAccountPasskeyEncryption(
this.db,
userId,
credentialId,
encryptedUserKey,
encryptedPublicKey,
encryptedPrivateKey,
updatedAt
);
}
async deleteAccountPasskeyCredential(
userId: string,
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
}
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
await saveStoredAccountPasskeyChallenge(this.db, challenge);
}
async consumeAccountPasskeyChallenge(
challengeHash: string,
scope: AccountPasskeyChallengeScope,
userId: string | null,
nowMs: number = Date.now()
): Promise<AccountPasskeyChallenge | null> {
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
}
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {
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> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
}
@@ -326,6 +532,10 @@ export class StorageService {
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> {
await saveStoredFolder(this.db, folder);
}
@@ -364,6 +574,10 @@ export class StorageService {
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> {
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
}
@@ -372,6 +586,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id);
}
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
await deleteStoredAttachmentForUser(this.db, id, userId);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
}
@@ -392,6 +610,10 @@ export class StorageService {
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> {
await deleteStoredAttachmentsByCipher(this.db, cipherId);
}
@@ -412,9 +634,13 @@ export class StorageService {
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
deviceSessionStamp?: string | null,
securityStamp?: string | null,
clientType?: string | null,
absoluteExpiresAtMs?: number | null
): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
const now = Date.now();
const expiresAt = expiresAtMs ?? (now + LIMITS.auth.refreshTokenDefaultSlidingTtlMs);
await saveStoredRefreshToken(
this.db,
this.refreshTokenKey.bind(this),
@@ -423,7 +649,10 @@ export class StorageService {
userId,
expiresAt,
deviceIdentifier,
deviceSessionStamp
deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
}
@@ -432,7 +661,6 @@ export class StorageService {
this.db,
this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this),
this.saveRefreshToken.bind(this),
this.deleteRefreshToken.bind(this),
token
);
@@ -453,6 +681,10 @@ export class StorageService {
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> {
await saveStoredSend(this.db, this.safeBind.bind(this), send);
}
@@ -494,11 +726,16 @@ export class StorageService {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
}
// Keep a short overlap window for rotated refresh token to reduce
// multi-context refresh races (e.g. browser extension popup/background).
// Expiry is only tightened, never extended.
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs);
async extendRefreshTokenExpiry(token: string, requestedExpiresAtMs: number, nowMs: number = Date.now()): Promise<boolean> {
return extendStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, requestedExpiresAtMs, nowMs);
}
async bindRefreshTokenSecurityStamp(token: string, securityStamp: string): Promise<void> {
await bindStoredRefreshTokenSecurityStamp(this.db, this.refreshTokenKey.bind(this), token, securityStamp);
}
async bindRefreshTokenDeviceStamp(token: string, deviceSessionStamp: string): Promise<void> {
await bindStoredRefreshTokenDeviceStamp(this.db, this.refreshTokenKey.bind(this), token, deviceSessionStamp);
}
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
@@ -539,6 +776,10 @@ export class StorageService {
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(
userId: string,
deviceIdentifier: string,
@@ -559,6 +800,27 @@ export class StorageService {
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> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
@@ -571,6 +833,49 @@ export class StorageService {
return deleteStoredDevicesByUserId(this.db, userId);
}
// --- Auth requests / Login with device ---
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
await createStoredAuthRequest(this.db, request);
}
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestById(this.db, id);
}
async 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[]> {
return listStoredTrustedTokenSummaries(this.db, userId);
}
@@ -583,6 +888,10 @@ export class StorageService {
return deleteStoredTrustedTokensByUserId(this.db, userId);
}
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken(
@@ -599,6 +908,24 @@ export class StorageService {
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 ---
async getRevisionDate(userId: string): Promise<string> {
+93
View File
@@ -0,0 +1,93 @@
[
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
]
@@ -0,0 +1,15 @@
{
"source": "https://github.com/bitwarden/server",
"ref": "main",
"generatedAt": "2026-05-05T00:00:00.000Z",
"rulesCount": 91,
"domainsCount": 436,
"sourceFiles": [
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
"src/Core/Utilities/StaticStore.cs"
],
"sourceUrls": [
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
]
}
+3
View File
@@ -0,0 +1,3 @@
[
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
]
+200 -7
View File
@@ -2,6 +2,7 @@
export interface Env {
DB: D1Database;
NOTIFICATIONS_HUB: DurableObjectNamespace;
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
@@ -10,16 +11,20 @@ export interface Env {
// Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string;
TOTP_SECRET?: string;
WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string;
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 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
export interface Attachment {
id: string;
@@ -50,11 +55,45 @@ export interface User {
verifyDevices?: boolean;
totpSecret: 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;
createdAt: string;
updatedAt: string;
}
export interface UserDomainSettings {
userId: string;
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
excludedGlobalEquivalentDomains: number[];
updatedAt: string | null;
}
export interface CustomEquivalentDomain {
id: string;
domains: string[];
excluded: boolean;
}
export interface GlobalEquivalentDomain {
type: number;
domains: string[];
excluded: boolean;
[key: string]: unknown;
}
export interface DomainRulesResponse {
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
globalEquivalentDomains: GlobalEquivalentDomain[];
object: 'domains';
}
export interface Invite {
code: string;
createdBy: string;
@@ -68,9 +107,13 @@ export interface Invite {
export interface AuditLog {
id: string;
actorUserId: string | null;
actorEmail?: string | null;
action: string;
category: 'auth' | 'security' | 'device' | 'data' | 'system';
level: 'info' | 'warn' | 'error' | 'security';
targetType: string | null;
targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null;
createdAt: string;
}
@@ -81,6 +124,10 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SSHKey = 5,
BankAccount = 6,
DriversLicense = 7,
Passport = 8,
}
export interface CipherLoginUri {
@@ -115,6 +162,52 @@ export interface CipherSshKey {
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 {
title: string | null;
firstName: string | null;
@@ -165,6 +258,9 @@ export interface Cipher {
identity: CipherIdentity | null;
secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
bankAccount?: CipherBankAccount | null;
driversLicense?: CipherDriversLicense | null;
passport?: CipherPassport | null;
fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null;
reprompt: number;
@@ -196,17 +292,78 @@ export interface Device {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
pushUuid: string | null;
pushToken: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential {
id: string;
userId: string;
purpose: 'login' | 'twoFactor';
name: string;
publicKey: string;
credentialId: string;
counter: number;
type: string | null;
aaGuid: string | null;
transports: string[] | null;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
supportsPrf: boolean;
createdAt: string;
updatedAt: string;
}
export type AccountPasskeyChallengeScope =
| 'Authentication'
| 'CreateCredential'
| 'UpdateKeySet'
| 'TwoFactorAuthentication'
| 'TwoFactorCreate';
export interface AccountPasskeyChallenge {
challengeHash: string;
scope: AccountPasskeyChallengeScope;
userId: string | null;
expiresAt: number;
usedAt: number | null;
createdAt: number;
}
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export type AuthRequestType = 0 | 1 | 2;
export interface AuthRequestRecord {
id: string;
userId: string;
organizationId: string | null;
type: AuthRequestType;
requestDeviceIdentifier: string;
requestDeviceType: number;
requestIpAddress: string | null;
requestCountryName: string | null;
responseDeviceIdentifier: string | null;
accessCode: string;
publicKey: string;
key: string | null;
masterPasswordHash: string | null;
approved: boolean | null;
creationDate: string;
responseDate: string | null;
authenticationDate: string | null;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
@@ -217,6 +374,7 @@ export interface DeviceResponse {
type: number;
creationDate: string;
revisionDate: string;
lastActivityDate?: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
@@ -244,6 +402,11 @@ export interface RefreshTokenRecord {
expiresAt: number;
deviceIdentifier: string | null;
deviceSessionStamp: string | null;
securityStamp: string | null;
createdAt: number | null;
lastUsedAt: number | null;
absoluteExpiresAt: number | null;
clientType: string | null;
}
export interface TrustedDeviceTokenSummary {
@@ -340,6 +503,14 @@ export interface MasterPasswordUnlock {
Object: string;
}
export interface WebAuthnPrfDecryptionOption {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
Object?: string;
}
export interface UserDecryptionOptions {
HasMasterPassword: boolean;
Object: string;
@@ -347,6 +518,7 @@ export interface UserDecryptionOptions {
MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null;
KeyConnectorOption: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
}
// API Response types
@@ -367,8 +539,18 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireNumbers: boolean;
requireSpecial: boolean;
enforceOnLogin: boolean;
Object: string;
object?: string;
} | null;
ApiUseKeyConnector?: boolean;
AccountKeys?: any | null;
@@ -392,17 +574,19 @@ export interface ProfileResponse {
masterPasswordHint: string | null;
culture: string;
twoFactorEnabled: boolean;
yubikeyEnabled?: boolean;
key: string;
privateKey: string | null;
accountKeys: any | null;
securityStamp: string;
organizations: any[];
organizationsNew?: any[];
providers: any[];
providerOrganizations: any[];
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
verifyDevices?: boolean;
verifyDevices: boolean;
role?: UserRole;
status?: UserStatus;
object: string;
@@ -421,6 +605,9 @@ export interface CipherResponse {
identity: CipherIdentity | null;
secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
bankAccount: CipherBankAccount | null;
driversLicense: CipherDriversLicense | null;
passport: CipherPassport | null;
fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null;
reprompt: number;
@@ -461,12 +648,18 @@ export interface SyncResponse {
ciphers: CipherResponse[];
domains: any;
policies: any[];
policiesNew?: any[];
sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
V2UpgradeToken?: {
WrappedUserKey1: string;
WrappedUserKey2: string;
} | null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients
+299
View File
@@ -0,0 +1,299 @@
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
WebAuthnCredential,
} from '@simplewebauthn/server';
import type {
AccountPasskeyChallengeScope,
AccountPasskeyCredential,
AccountPasskeyPrfStatus,
Env,
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000;
const DEFAULT_RP_NAME = 'NodeWarden';
interface AccountPasskeyTokenPayload {
typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE;
scope: AccountPasskeyChallengeScope;
challenge: string;
userId: string | null;
rpId: string;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
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> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function sha256Base64Url(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', textBytes(value));
return bytesToBase64Url(new Uint8Array(digest));
}
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
}
export async function createAccountPasskeyToken(
env: Env,
input: {
scope: AccountPasskeyChallengeScope;
challenge: string;
userId?: string | null;
rpId: string;
ttlMs?: number;
}
): Promise<string> {
const now = Date.now();
const payload: AccountPasskeyTokenPayload = {
typ: ACCOUNT_PASSKEY_TOKEN_TYPE,
scope: input.scope,
challenge: input.challenge,
userId: input.userId ?? null,
rpId: input.rpId,
iat: now,
exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)),
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyAccountPasskeyToken(
env: Env,
token: string,
scope: AccountPasskeyChallengeScope
): Promise<AccountPasskeyTokenPayload | null> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return null;
const payload = decodeJson<AccountPasskeyTokenPayload>(parts[1]);
if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null;
if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null;
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } {
const url = new URL(request.url);
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
return { rpId, rpName, origins: Array.from(origins) };
}
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
}
export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null;
try {
const bytes = base64UrlToBytes(userHandle);
const officialGuid = dotNetGuidBytesToUuid(bytes);
if (officialGuid) return officialGuid;
const decoded = new TextDecoder().decode(bytes);
return decoded.trim() || null;
} catch {
return null;
}
}
export function accountPasskeyPrfStatus(credential: Pick<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): AccountPasskeyPrfStatus {
if (!credential.supportsPrf) return 2;
if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0;
return 1;
}
export function buildWebAuthnPrfOption(
credential: AccountPasskeyCredential
): WebAuthnPrfDecryptionOption | null {
if (accountPasskeyPrfStatus(credential) !== 0) return null;
return {
EncryptedPrivateKey: credential.encryptedPrivateKey!,
EncryptedUserKey: credential.encryptedUserKey!,
CredentialId: credential.credentialId,
Transports: credential.transports || [],
Object: 'webAuthnPrfDecryptionOption',
};
}
export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record<string, unknown> {
const prfStatus = accountPasskeyPrfStatus(credential);
return {
Id: credential.id,
id: credential.id,
Name: credential.name,
name: credential.name,
PrfStatus: prfStatus,
prfStatus,
EncryptedPublicKey: credential.encryptedPublicKey,
encryptedPublicKey: credential.encryptedPublicKey,
EncryptedUserKey: credential.encryptedUserKey,
encryptedUserKey: credential.encryptedUserKey,
CreationDate: credential.createdAt,
RevisionDate: credential.updatedAt,
Object: 'webauthnCredential',
object: 'webauthnCredential',
};
}
export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential {
return {
id: credential.credentialId,
publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)),
counter: credential.counter,
transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined,
};
}
export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return {
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
attestationObject: normalizeWebAuthnBase64(response.attestationObject),
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
},
};
}
export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return {
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
signature: normalizeWebAuthnBase64(response.signature),
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
},
};
}
export function normalizeAccountPasskeyName(value: unknown): string {
const normalized = String(value || '').trim();
return (normalized || 'Account passkey').slice(0, 128);
}
export function normalizeTransports(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const transports = value.map((item) => String(item || '').trim()).filter(Boolean);
return transports.length ? transports.slice(0, 12) : null;
}
export function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
+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 { DEFAULT_DEV_SECRET, Env } from '../types';
import { Env } from '../types';
import { errorResponse } from './response';
export interface DirectUploadPayload {
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
fileNameMismatchMessage?: string;
}
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03';
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 {
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 secret;
}
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
}
function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
const contentType = request.headers.get('content-type') || '';
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 file = formData.get('data') as File | null;
if (!file) {
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+40
View File
@@ -0,0 +1,40 @@
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,
// New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role,
status: user.status,
object: 'profile',
};
}
+41 -24
View File
@@ -1,4 +1,10 @@
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 DEFAULT_CORS_HEADERS = [
@@ -18,42 +24,40 @@ const DEFAULT_CORS_HEADERS = [
'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 {
return (
path.startsWith('/icons/')
|| path.startsWith('/fill-assist/')
|| path === '/v1/assetlinks:check'
|| path === '/api/v1/assetlinks:check'
|| path === '/config'
|| path === '/api/config'
|| 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 origin = request.headers.get('Origin');
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
if (!origin) {
return { allowOrigin: null, allowCredentials: false };
const originHeader = request.headers.get('Origin');
if (!originHeader) {
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', 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') || '')
.split(',')
.map((value) => value.trim())
@@ -67,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const corsPolicy = getCorsPolicy(request);
const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
@@ -81,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors(
request: Request,
response: Response
response: Response,
env: Env
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -90,7 +95,7 @@ export function applyCors(
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v);
}
@@ -98,7 +103,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
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:");
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
@@ -132,8 +139,17 @@ export function errorResponse(message: string, status: number = 400): Response {
);
}
export function unsupportedResponse(message: string = 'This feature is not supported by this server.'): Response {
return errorResponse(message, 501);
}
// Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
export function identityErrorResponse(
message: string,
error: string = 'invalid_grant',
status: number = 400,
headers: Record<string, string> = {}
): Response {
return jsonResponse(
{
error: error,
@@ -143,15 +159,16 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
Object: 'error',
},
},
status
status,
{ 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers }
);
}
// Handle CORS preflight
export function handleCors(request: Request): Response {
export function handleCors(request: Request, env: Env): Response {
return new Response(null, {
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, '');
}
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);
if (!/^\d{6}$/.test(token)) return false;
if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw);
if (!secret) return false;
if (!secret) return null;
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++) {
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.
const a = new TextEncoder().encode(expected);
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++) {
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 {
+5 -2
View File
@@ -1,4 +1,4 @@
import { User, UserDecryptionOptions } from '../types';
import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return '';
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey,
publicKey,
signedPublicKey: null,
Object: 'publicKeyEncryptionKeyPair',
},
Object: 'privateKeys',
@@ -40,7 +41,8 @@ export function buildMasterPasswordUnlock(
}
export function buildUserDecryptionOptions(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
): UserDecryptionOptions {
return {
HasMasterPassword: true,
@@ -48,6 +50,7 @@ export function buildUserDecryptionOptions(
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOption,
};
}
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+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;
}
+7 -1
View File
@@ -9,7 +9,7 @@
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
connect-src 'self' https://api.pwnedpasswords.com;
font-src 'self';
form-action 'self';
base-uri 'self';
@@ -18,6 +18,12 @@
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>NodeWarden</title>
<style>
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

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