Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub e0d70a666f chore(deps): bump the npm-minor-and-patch group across 1 directory with 12 updates
Bumps the npm-minor-and-patch group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.2` | `5.101.4` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.26` | `2.8.34` |
| [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) | `1.22.0` | `1.28.0` |
| [preact](https://github.com/preactjs/preact) | `10.29.3` | `10.29.7` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.5` | `2.10.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.2` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.3.2` | `1.4.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.25` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.2.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.116.0` |



Updates `@tanstack/react-query` from 5.101.2 to 5.101.4
- [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.4/packages/react-query)

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

Updates `lucide-preact` from 1.22.0 to 1.28.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.28.0/packages/lucide-preact)

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

Updates `@preact/preset-vite` from 2.10.5 to 2.10.6
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Changelog](https://github.com/preactjs/preset-vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preset-vite/commits)

Updates `@types/node` from 26.0.1 to 26.1.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [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.5.2...10.5.4)

Updates `opencc-js` from 1.3.2 to 1.4.1
- [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.3.2...v1.4.1)

Updates `postcss` from 8.5.23 to 8.5.25
- [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.23...8.5.25)

Updates `tsx` from 4.22.4 to 4.23.1
- [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.22.4...v4.23.1)

Updates `vite` from 8.1.3 to 8.2.0
- [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/create-vite@8.2.0/packages/vite)

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

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: lucide-preact
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.1.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.116.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-08-02 21:04:59 +00:00
shuaiplus 652952379b fix: stop accepting backup blob credentials from the URL 2026-07-26 16:00:39 +08:00
shuaiplus 34fd2f0259 build: optimize Rolldown chunk splitting 2026-07-25 22:49:02 +08:00
EnvyBackyardandGitHub df493b4697 feat: expand password generator options (#329) 2026-07-25 22:23:02 +08:00
shuaiplus f6403b8cab fix: upgrade PostCSS to patch path traversal vulnerability 2026-07-25 21:44:22 +08:00
shuaiplus a0128908e1 fix: align API responses with Bitwarden 2026.7 clients 2026-07-25 21:25:51 +08:00
shuaiplus 8e5d9e294b fix: update sharp to 0.35.0 2026-07-23 18:28:00 +08:00
shuaiplus 6ffdf05dc6 fix: enable official desktop browser integration settings
Return Bitwarden's desktop-ui-settings-dialog feature state so supported desktop clients use the official settings dialog with browser integration controls.

Fixes #315
2026-07-23 01:09:44 +08:00
shuaiplus 82d9f61163 fix: handle unavailable browser cryptography
Detect insecure or unsupported browser contexts before account registration and show localized HTTPS guidance instead of leaking a SubtleCrypto runtime error.

Fixes #320
2026-07-23 00:54:52 +08:00
zu1kandshuaiplus f761fffd58 fix: align WebAuthn connectors with Bitwarden clients
Add official-compatible mobile and desktop connector flows, preserve exact .html asset paths, and cover the protocol and framing behavior with regression tests.

Fixes #326
2026-07-23 00:39:34 +08:00
shuaiplus 39c076b2e1 chore: release v1.8.0 2026-07-17 11:38:42 +08:00
shuaiplus a50a503788 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-17 11:33:41 +08:00
shuaiplus 6cbc06f833 feat: add Web Vault visibility switch 2026-07-17 11:32:43 +08:00
ph4nt0merandGitHub 72d8ec9cba fix: auto-refresh remote backup directory when cache is stale (#312)
When entering the cloud backup page, the remote backup directory list only showed cached data and required a manual click of the refresh button to see new backup files. This change adds a TTL-based auto-refresh that fetches fresh data when the cache is older than 5 minutes.

Changes:
- Added refreshedAt tracking per cache key in persisted state
- Added REMOTE_BROWSER_REFRESH_TTL_MS (5 min) constant
- Added useEffect that triggers auto-refresh when destination is selected and cached data is stale
- Stamped refresh timestamps after successful API responses
- Cleaned up timestamps on destination delete and settings save
2026-07-16 12:30:00 +08:00
shuaiplus 299eda597f fix(auth): align API keys and exclude device trust backups 2026-07-13 17:41:00 +08:00
shuaiplus 19de8d6e57 fix(auth): require complete password change key data 2026-07-13 17:02:59 +08:00
shuaiplus 63b642b251 fix(storage): safely batch folder deletion binds 2026-07-13 13:30:52 +08:00
shuaiplus e943357067 fix(web): preserve cipher types 6-8 during import 2026-07-13 13:13:42 +08:00
shuaiplus 573451c52f feat(yubico): refactor Yubico credential management and enhance settings UI 2026-07-13 13:05:04 +08: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
180 changed files with 33349 additions and 4666 deletions
-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.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
groups:
npm-minor-and-patch:
update-types:
- "minor"
- "patch"
ignore:
- dependency-name: "tailwindcss"
update-types:
- "version-update:semver-major"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:10"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 0
groups:
github-actions:
patterns:
- "*"
-467
View File
@@ -1,467 +0,0 @@
const fs = require('fs');
const path = require('path');
/**
* Security Report Generator (Node.js)
* Better, faster, and more maintainable than Bash.
*/
class SecurityReport {
constructor() {
this.results = {
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
coverage: { actions: 0, js: 0, ts: 0 },
artifactUris: []
};
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
this.runId = process.env.GITHUB_RUN_ID || '0';
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
this.locales = {
zh: {
filename: 'security-report-cn.md',
switcher: '[English](security-report.md) | 中文',
title: '🛡️ 安全审计与透明度报告',
grade: '安全评级',
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
auditTime: '📅 审计时间',
runId: '📝 运行 ID',
env: '🛠️ 环境',
dashboard: '📉 实时安全仪表盘',
tool: '工具',
status: '状态',
findings: '发现项',
leaks: '泄露',
vulns: '漏洞',
alerts: '告警',
coverageTitle: '🔍 扫描覆盖范围',
module: '模块',
auditedFiles: '已审计文件',
coverage: '覆盖率',
detailedFindings: '🔍 详细发现项',
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
snykTitle: '📦 第三方依赖',
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
package: '软件包',
severity: '严重程度',
description: '描述',
fixPlan: '修复方案',
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
codeqlSummary: '#### 摘要',
rulesChecked: '已检查规则',
totalAlerts: '告警总数',
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
ruleId: '规则 ID',
level: '级别',
location: '位置',
auditedList: '📂 已审计文件列表',
guideTitle: '⚠️ 操作指南',
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
},
en: {
filename: 'security-report.md',
switcher: 'English | [中文](security-report-cn.md)',
title: '🛡️ Security Audit & Transparency Report',
grade: 'Security Grade',
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
auditTime: '📅 Audit Time',
runId: '📝 Run ID',
env: '🛠️ Environment',
dashboard: '📉 Real-time Security Dashboard',
tool: 'Tool',
status: 'Status',
findings: 'Findings',
leaks: 'Leaks',
vulns: 'Vulns',
alerts: 'Alerts',
coverageTitle: '🔍 Scan Coverage',
module: 'Module',
auditedFiles: 'Audited Files',
coverage: 'Coverage',
detailedFindings: '🔍 Detailed Findings',
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
snykTitle: '📦 Third-party Dependencies',
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
package: 'Package',
severity: 'Severity',
description: 'Description',
fixPlan: 'Fix Plan',
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
codeqlSummary: '#### Summary',
rulesChecked: 'Rules Checked',
totalAlerts: 'Total Alerts',
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
ruleId: 'Rule ID',
level: 'Level',
location: 'Location',
auditedList: '📂 Audited File List',
guideTitle: '⚠️ Action Guide',
guideDesc: 'If you see a **FAIL** status or serious code issues:',
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
trivySafe: '✅ **SAFE**: No container configuration defects found.'
}
};
}
// --- Data Parsers ---
async parseCodeQL() {
const sarifPath = 'sarif-results';
if (!fs.existsSync(sarifPath)) return;
const files = this.globFiles(sarifPath, '.sarif');
let totalAlerts = 0;
let rulesSet = new Set();
let findings = [];
let artifactUris = new Set();
for (const file of files) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const run of data.runs || []) {
// Collect Rules
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
(run.tool.extensions || []).forEach(ext => {
(ext.rules || []).forEach(r => rulesSet.add(r.id));
});
// Collect Results
for (const res of run.results || []) {
totalAlerts++;
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
findings.push({
id: res.ruleId,
level: res.level || 'warning',
path: loc.artifactLocation?.uri || 'Global',
line: loc.region?.startLine || '-',
message: res.message?.text || 'No description'
});
}
// Track Coverage (Deduplicated)
(run.artifacts || []).forEach(art => {
const uri = art.location?.uri || '';
if (uri) artifactUris.add(uri);
});
}
}
this.results.artifactUris = Array.from(artifactUris).sort();
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
this.results.codeql.alertCount = totalAlerts;
this.results.codeql.rulesCount = rulesSet.size;
this.results.codeql.findings = findings;
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
}
async parseSnyk() {
const jsonPath = 'snyk_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const projects = Array.isArray(data) ? data : [data];
let vulnTotal = 0;
let findings = [];
for (const proj of projects) {
const vulns = proj.vulnerabilities || [];
vulnTotal += vulns.length;
vulns.forEach(v => {
findings.push({
pkg: `${v.packageName}@${v.version}`,
severity: v.severity,
title: v.title,
url: v.url,
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
});
});
}
this.results.snyk.vulnCount = vulnTotal;
this.results.snyk.findings = findings;
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
} catch (e) {
console.error('Error parsing Snyk JSON:', e.message);
}
}
async parseGitleaks() {
const files = this.globFiles('.', 'results.sarif');
if (files.length === 0) return;
try {
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
let leaks = 0;
let findings = [];
for (const run of data.runs || []) {
for (const res of run.results || []) {
leaks++;
findings.push({
id: res.ruleId,
message: res.message.text,
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
});
}
}
this.results.gitleaks.leaksCount = leaks;
this.results.gitleaks.findings = findings;
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
} catch (e) {
console.error('Error parsing Gitleaks SARIF:', e.message);
}
}
async parseTrivy() {
const jsonPath = 'trivy_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let misconfigs = 0;
let findings = [];
(data.Results || []).forEach(res => {
(res.Misconfigurations || []).forEach(m => {
misconfigs++;
findings.push({
id: m.ID,
severity: m.Severity,
title: m.Title,
message: m.Message,
status: m.Status,
target: res.Target
});
});
});
this.results.trivy.misconfigCount = misconfigs;
this.results.trivy.findings = findings;
if (misconfigs > 0) this.results.trivy.status = 'WARN';
} catch (e) {
console.error('Error parsing Trivy JSON:', e.message);
}
}
generateTable(type, t) {
let files = [];
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
if (files.length === 0) return `> ${t.noFiles}\n`;
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
files.forEach(f => {
const filename = path.basename(f);
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
});
return table;
}
// --- Renderers ---
generateMarkdown(localeKey) {
const { codeql, snyk, gitleaks, coverage } = this.results;
const t = this.locales[localeKey];
// Calculate Grade
let grade = 'A+';
let gradeColor = 'success';
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
const badge = (label, value, color) => `![${label}](https://img.shields.io/badge/${label.replace(/ /g, '_')}-${value}-${color}?style=for-the-badge)`;
let md = `# ${t.title}\n\n`;
md += `${t.switcher}\n\n`;
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
md += `${t.important}\n\n`;
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
md += `---\n\n## ${t.dashboard}\n\n`;
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
md += `---\n\n## ${t.coverageTitle}\n\n`;
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
md += `| :--- | :---: | :---: |\n`;
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
md += `---\n\n## ${t.detailedFindings}\n\n`;
// Gitleaks Section
md += `### ${t.gitleaksTitle}\n`;
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
if (gitleaks.findings.length > 0) {
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :--- | :--- |\n`;
gitleaks.findings.forEach(f => {
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
});
} else {
md += `${t.gitleaksSafe}\n`;
}
// Trivy Section
md += `\n### ${t.trivyTitle}\n`;
md += `${t.trivyDesc}\n\n`;
if (this.results.trivy.findings.length > 0) {
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
this.results.trivy.findings.forEach(f => {
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
});
} else {
md += `${t.trivySafe}\n`;
}
// Snyk Section
md += `\n### ${t.snykTitle}\n`;
if (snyk.findings.length > 0) {
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
snyk.findings.forEach(f => {
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
});
} else {
md += `${t.snykSafe}\n`;
}
// CodeQL Section
md += `\n### ${t.codeqlTitle}\n`;
if (codeql.findings.length > 0) {
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
codeql.findings.forEach(f => {
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
const prefix = f.id.split('/')[0];
const langMap = {
'js': 'javascript',
'actions': 'github-actions',
'cpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'java': 'java',
'py': 'python',
'rb': 'ruby',
'swift': 'swift'
};
const langPath = langMap[prefix] || 'javascript';
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
});
} else {
md += `${t.codeqlSafe}\n`;
}
// Audited Files List
md += `\n### ${t.auditedList}\n`;
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
md += this.generateTable('actions', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
md += this.generateTable('js', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
md += this.generateTable('ts', t);
md += `\n</details>\n\n`;
// Action Guide
md += `--- \n\n## ${t.guideTitle}\n\n`;
md += `${t.guideDesc}\n`;
md += `${t.guideStep1}\n`;
md += `${t.guideStep2}\n`;
md += `${t.guideStep3}\n\n`;
md += `--- \n\n${t.footer}`;
return md;
}
// --- Helpers ---
getBadge(status) {
if (status === 'PASS') return '![Pass](https://img.shields.io/badge/Status-PASS-success?style=for-the-badge)';
if (status === 'WARN' || status === 'INFO') return '![Warning](https://img.shields.io/badge/Status-NOTICE-orange?style=for-the-badge)';
return '![Fail](https://img.shields.io/badge/Status-FAIL-red?style=for-the-badge)';
}
globFiles(dir, ext) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
results = results.concat(this.globFiles(fullPath, ext));
} else if (file.endsWith(ext)) {
results.push(fullPath);
}
}
return results;
}
async run() {
console.log('--- Security Report Generation Started ---');
await this.parseCodeQL();
await this.parseSnyk();
await this.parseGitleaks();
await this.parseTrivy();
for (const localeKey of Object.keys(this.locales)) {
const locale = this.locales[localeKey];
const markdown = this.generateMarkdown(localeKey);
fs.writeFileSync(locale.filename, markdown);
console.log(`Report generated successfully at ${locale.filename}`);
}
}
}
new SecurityReport().run().catch(err => {
console.error('Report generation failed:', err);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
name: "CodeQL Advanced"
on:
push:
branches:
- "**"
permissions:
contents: read
actions: read
security-events: write
packages: read
jobs:
analyze:
name: CodeQL Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
category: "/language:${{ matrix.language }}"
+200
View File
@@ -0,0 +1,200 @@
name: "Extra Security Scan"
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_SUMMARY: "true"
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
osv:
name: OSV Dependency Scan
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
permissions:
contents: read
actions: read
security-events: write
with:
scan-args: |-
--recursive
./
upload-sarif: true
fail-on-vuln: true
pnpm-audit:
name: pnpm audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Run pnpm audit
shell: bash
run: |
if [ ! -f pnpm-lock.yaml ]; then
echo "pnpm-lock.yaml not found, skip pnpm audit."
exit 0
fi
corepack enable
corepack prepare pnpm@10 --activate
pnpm audit --audit-level=high
semgrep:
name: Semgrep CE Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run Semgrep CE
shell: bash
run: |
docker run --rm \
-v "${PWD}:/src" \
-w /src \
semgrep/semgrep:latest \
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
if [ ! -f semgrep.sarif ]; then
cat > semgrep.sarif <<'EOF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "Semgrep",
"informationUri": "https://semgrep.dev",
"rules": []
}
},
"results": []
}
]
}
EOF
fi
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: semgrep.sarif
category: semgrep
actionlint:
name: GitHub Actions Syntax Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run actionlint
shell: bash
run: |
docker run --rm \
-v "${PWD}:/repo" \
-w /repo \
rhysd/actionlint:latest
zizmor:
name: GitHub Actions Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
with:
persona: auditor
min-severity: medium
min-confidence: medium
scorecard:
name: OpenSSF Scorecard
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: false
- name: Upload Scorecard SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: scorecard.sarif
category: openssf-scorecard
-142
View File
@@ -1,142 +0,0 @@
name: Security Scan
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Initialize CodeQL
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript, actions
build-mode: none
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/analyze@v4
with:
upload: true
output: sarif-results
- name: Install Gitleaks
if: env.ACT != 'true'
continue-on-error: true
run: |
GITLEAKS_VERSION="8.28.0"
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
- name: Secret Detection
if: env.ACT != 'true'
continue-on-error: true
run: |
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
- name: Install Project Dependencies
if: env.SECURITY_SNYK_TOKEN != ''
env:
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
run: |
echo "Preparing dependency lock files for security scanning..."
if [ -z "$SECURITY_PACKAGE" ]; then
echo "SECURITY_PACKAGE is empty, installing in root..."
npm install --package-lock-only
else
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
# Split by comma and install
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
echo "Installing in "$pkg"..."
npm install --prefix "$pkg" --package-lock-only
else
echo "Warning: Directory $pkg not found, skipping."
fi
done
fi
- name: Dependency Scan
id: snyk
if: env.SECURITY_SNYK_TOKEN != ''
continue-on-error: true
run: |
npm install -g snyk
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
- name: Check for Dockerfile
id: check_docker
run: |
if [ -f "Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Container Security Scan (Trivy)
if: steps.check_docker.outputs.exists == 'true'
continue-on-error: true
run: |
VERSION="0.56.1"
echo "Installing Trivy $VERSION..."
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
- name: Generate Security Report
run: |
# Gitleaks typically produces results.sarif if configured or by default in some versions
# We'll ensure it exists for our reporter
node .github/scripts/security.cjs
# Also append to step summary for immediate visibility in GHA UI
cat security-report.md >> $GITHUB_STEP_SUMMARY
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
- name: Upload Gitleaks Results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: results.sarif
category: gitleaks
- name: Upload Security Report Artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: security-report
if-no-files-found: ignore
path: |
security-report.md
security-report-cn.md
snyk_result.txt
snyk_result.json
trivy_result.json
results.sarif
sarif-results/*.sarif
+13 -4
View File
@@ -19,20 +19,29 @@ jobs:
sync-global-domains:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with:
branch: chore/sync-bitwarden-global-domains
delete-branch: true
-143
View File
@@ -1,143 +0,0 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
echo "mode=auto" >> $GITHUB_OUTPUT
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
fi
+3 -1
View File
@@ -43,7 +43,7 @@ tmp/
.tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/
nodewarden-wiki/
wiki/
AGENTS.md
settings.json
@@ -56,9 +56,11 @@ NodeWarden-compat/
.codex-upstream/bitwarden-browser/
.reasonix/
.upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json
opencode.jsonc
.cursor/
+78 -110
View File
@@ -3,95 +3,102 @@
</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="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
<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界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | | ✅ | `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| 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 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 域名
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
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
- To hide the Web Vault, add a text variable named `HIDE_WEB_VAULT` with the value `1` under **Workers settings → Variables and Secrets**. While enabled, server-hosted frontend pages and static assets return `404 Not Found`, while the login, sync, attachment, icon, notification, and other server endpoints used by Bitwarden clients remain available; an already installed or cached PWA can continue using its local frontend. Delete the variable (or change it to anything other than `1`) to restore the server-hosted Web Vault.
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
> [!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
@@ -100,85 +107,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
```
---
## 主要特性
### PWA 渐进式 Web 应用
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `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>
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Key Features
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+151
View File
@@ -0,0 +1,151 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 如需隐藏 Web Vault,在 Workers 的“设置 → 变量和机密”中添加文本变量 `HIDE_WEB_VAULT`,值设为 `1`。启用后,服务器上的前端页面和静态资源统一返回 `404 Not Found`,Bitwarden 客户端所需的登录、同步、附件、图标、通知等服务端接口仍可使用;已经安装或缓存的 PWA 可以继续使用本地前端。删除该变量(或将值改为非 `1`)即可恢复服务器上的 Web Vault。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 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>
+389
View File
@@ -0,0 +1,389 @@
<!--
Release note writing rules:
1. Always add the newest release at the top of this file; do not delete older release notes.
2. Move previous releases down unchanged, then write the new release above them.
3. Use this exact release structure:
- One H1 title: "# vX.Y.Z - Short English Summary".
- English section first with "### Added", "### Improved", and "### Fixed".
- Then a horizontal rule "---".
- Chinese section second with "### 新增", "### 改进", and "### 修复".
4. Use numbered items under each group. Each item must be:
- "1. **Short feature/fix title.** One concise paragraph explaining what changed and why it matters."
- No one-line commit dumps, no raw changelog lists, no vague marketing copy.
- Merge related commits into one readable item instead of listing every commit separately.
5. Keep the tone user-friendly and concrete:
- Explain behavior, compatibility, UI impact, migration impact, or bug impact in plain language.
- Be concise but specific; one item is usually 1-3 sentences.
- Avoid internal-only implementation details unless they explain a user-visible change.
6. Add GitHub commit links at the end of every numbered item:
- English: "Commit: [abc1234](...)." or "Commits: [abc1234](...), [def5678](...)."
- Chinese: "提交:[abc1234](...)。" or "提交:[abc1234](...)、[def5678](...)。"
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
-->
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
### Added
1. **Web Vault visibility control.** Cloudflare Workers deployments can now set `HIDE_WEB_VAULT=1` to return `404 Not Found` for server-hosted Web Vault pages and static assets while keeping Bitwarden login, sync, attachment, icon, notification, and compatibility endpoints available. Removing the variable restores the Web Vault, and already installed or cached PWAs can continue using their local frontend. Commit: [d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53).
### Improved
1. **Simpler desktop and mobile navigation.** The Web Vault now uses a consistent grouped sidebar with persistent expanded sections, clearer separation between tools, settings, and system management, and matching mobile settings navigation. This removes the previous layout-mode picker and makes frequently used destinations easier to find. Commit: [e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d).
1. **Safer YubiKey validation credential management.** Yubico validation credentials now use centralized, concurrency-safe initialization; only active administrators can view or replace configured credentials, and credential initialization and reconfiguration are recorded in the security audit log. Regular users can still enroll YubiKeys without gaining access to server-wide credentials. Commit: [573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437).
1. **Bitwarden-compatible personal API keys and safer backups.** Personal API keys can now be viewed after master-password verification and rotated without revoking unrelated sessions. Legacy hashed keys continue to authenticate until the user explicitly rotates them, while new backups exclude personal API keys and runtime authentication or device-trust state; older archives remain importable, but that runtime state is intentionally ignored during restore. Commit: [299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db).
### Fixed
1. **Unexpected logout during temporary refresh failures.** Refresh sessions now use client-aware sliding lifetimes with an absolute upper bound, and transient network or service failures no longer turn a locked Web Vault into a forced full login. The Web Vault preserves the locked session, shows a retry path, and keeps official desktop, mobile, browser-extension, and web-cookie flows compatible. Commit: [b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6).
1. **Complete key data required for master-password changes.** Password changes now reject incomplete or inconsistent authentication and unlock data, require the newly wrapped user key, and prevent KDF settings from being changed through the password-only endpoint. This avoids accepting a password update that could leave the account unable to decrypt its vault. Commit: [19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c).
1. **Extended vault item imports preserve their data.** Web imports now correctly map bank accounts, driver's licenses, and passports instead of reducing item types 6-8 to incomplete generic drafts. Import summaries also report these item types by name. Commit: [e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab).
1. **Reliable bulk folder deletion.** Bulk folder deletion now calculates Cloudflare D1 bind limits correctly and batches the related cipher cleanup and folder removal statements safely, preventing large selections from failing or leaving partially updated folder references. Commit: [63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca).
1. **Fresh remote backup directory listings.** Opening a remote backup destination now automatically refreshes directory data when the saved cache is older than five minutes, so newly created backup files appear without requiring a manual refresh. Commit: [72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4).
---
### 新增
1. **Web Vault 显示开关。** Cloudflare Workers 部署现在可以设置 `HIDE_WEB_VAULT=1`,让服务器托管的 Web Vault 页面和静态资源统一返回 `404 Not Found`,同时继续提供 Bitwarden 客户端所需的登录、同步、附件、图标、通知和兼容接口。删除变量即可恢复 Web Vault,已经安装或缓存的 PWA 仍可继续使用本地前端。提交:[d990b2c](https://github.com/shuaiplus/nodewarden/commit/d990b2c71ff43a43f4598cad715b09c673e87b53)。
### 改进
1. **更简洁的桌面端和移动端导航。** Web Vault 现在统一使用分组侧边栏,并会保存各分组的展开状态;工具、设置和系统管理的层级更加清楚,移动端设置入口也同步调整。原有布局模式选择器已移除,常用功能更容易查找。提交:[e25ec15](https://github.com/shuaiplus/nodewarden/commit/e25ec159bb2cd07ec6b3a794032a8a2978340d3d)。
1. **更安全的 YubiKey 验证凭据管理。** Yubico 验证凭据现在通过统一且可防并发冲突的流程初始化;只有状态正常的管理员能够查看或替换服务器凭据,初始化和重新配置操作也会写入安全审计日志。普通用户仍可绑定 YubiKey,但无法接触服务器级凭据。提交:[573451c](https://github.com/shuaiplus/nodewarden/commit/573451c52f02978dee5ab8379ff86e59da805437)。
1. **兼容 Bitwarden 的个人 API Key 与更安全的备份。** 用户现在可以在验证主密码后查看个人 API Key,并在不撤销无关会话的情况下轮换密钥。旧版哈希密钥在用户主动轮换前仍可继续认证;新备份不再包含个人 API Key、运行中的认证状态或设备信任状态,旧备份仍能导入,但其中这类运行时状态会被主动忽略。提交:[299eda5](https://github.com/shuaiplus/nodewarden/commit/299eda597ff8a07bf0b7ddfb6e3a5e7f800096db)。
### 修复
1. **临时刷新失败导致意外退出登录。** 刷新会话现在根据客户端采用滑动有效期,并保留绝对最长时限;临时网络或服务故障不会再把已锁定的 Web Vault 直接变成完整登录页。Web Vault 会保留锁定会话并提供重试入口,同时兼容官方桌面端、移动端、浏览器扩展和 Web Cookie 会话。提交:[b731a01](https://github.com/shuaiplus/nodewarden/commit/b731a014f1d86552615110cdf543f809a5c6a7e6)。
1. **修改主密码时必须提交完整密钥数据。** 密码修改接口现在会拒绝不完整或不一致的认证与解锁数据,强制要求新的用户密钥包装结果,并禁止通过仅修改密码的接口顺带更改 KDF 设置,避免出现密码已更新但保险库无法解密的账户状态。提交:[19de8d6](https://github.com/shuaiplus/nodewarden/commit/19de8d6e5769be463f973c8f2ec4de2e1530005c)。
1. **扩展保险库条目导入时完整保留数据。** Web 导入现在会正确映射银行账户、驾驶证和护照,不再把类型 6-8 转换为字段缺失的通用草稿;导入结果摘要也会按名称显示这些条目类型。提交:[e943357](https://github.com/shuaiplus/nodewarden/commit/e943357067236deeaa76ac0003020fa986abf2ab)。
1. **可靠的批量文件夹删除。** 批量删除文件夹时现在会正确计算 Cloudflare D1 的绑定参数上限,并安全批处理密码条目引用清理和文件夹删除语句,避免大量选择时失败或留下只更新了一部分的文件夹引用。提交:[63b642b](https://github.com/shuaiplus/nodewarden/commit/63b642b2511207f435546802e197b6842f5c7aca)。
1. **远端备份目录自动显示最新文件。** 打开远端备份目标时,如果已保存的目录缓存超过五分钟,页面会自动获取最新数据,新生成的备份文件无需手动点击刷新即可出现。提交:[72d8ec9](https://github.com/shuaiplus/nodewarden/commit/72d8ec9cbadcb1b74d032deb5e5eea96e785d9c4)。
# v1.7.4 - Password Tools, Localization, and Security Hardening
### Added
1. **Built-in password generator.** The web vault now provides a dedicated generator for creating strong, configurable passwords, including memorable passphrases backed by the EFF word list. Generated values can be used while creating or editing vault items without leaving the app. Commits: [dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3), [fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428).
1. **Password security dashboard.** A new password-security view scans the vault and reports weak, reused, exposed, and missing two-factor-authentication passwords, helping users prioritize account cleanup. Commit: [99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9).
1. **Duplicate-item selection tools.** Duplicate results are grouped by color and sorted alphabetically, and the vault now lets users select the unique items from a duplicate group for quicker review and cleanup. Commits: [0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c), [39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118).
1. **Five additional interface languages.** Finnish, German, French, Italian, and Swedish are now available in the web vault, expanding the supported interface languages to ten. Commits: [dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329), [9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe).
### Improved
1. **Passkey unlock and Bitwarden-client compatibility.** Account passkeys can now unlock the web vault directly, and supported FIDO2 origins, worker-routed fill-assist, Android asset-links checks, and web bootstrap paths are recognized more consistently. Commits: [8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7), [db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2).
1. **Clearer offline and Send experiences.** The app now clearly indicates when it is operating offline, Send pages use improved date formatting, and closing dialogs retain their title through the animation to avoid a visual flash. Commits: [aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072), [04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744), [525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b).
1. **Simplified new-device sign-in.** New-device verification is no longer enforced, removing an extra login step for devices that are otherwise able to authenticate successfully. Commit: [14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404).
### Fixed
1. **Backup destination SSRF protection.** Backup destination validation now rejects IPv6 loopback addresses, closing a path that could otherwise bypass loopback-host safeguards. Commit: [3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8).
1. **Sensitive admin actions require the master password.** Administrative operations and device-wipe actions now require master-password confirmation, reducing the impact of an unattended or compromised web session. Commit: [fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2).
---
### 新增
1. **内置密码生成器。** Web 保险库现在提供专用的密码生成器,可创建高强度且可配置的密码,也支持基于 EFF 词表的易记口令短语。生成的值可直接用于新建或编辑保险库条目,无需离开应用。提交:[dfc9800](https://github.com/shuaiplus/nodewarden/commit/dfc98008cb58e9ed01b21ba158bb2584291462a3)、[fb37679](https://github.com/shuaiplus/nodewarden/commit/fb376797d266003f8e23b64870f3638fde35d428)。
1. **密码安全仪表板。** 新增的密码安全视图会扫描保险库,并报告弱密码、重复密码、已泄露密码和缺少双因素认证的密码,帮助用户优先处理需要清理的账户。提交:[99b5027](https://github.com/shuaiplus/nodewarden/commit/99b50275a6a845e6ebbbae4d647350df939457f9)。
1. **重复条目选择工具。** 重复项结果会按颜色分组并按字母顺序排序;保险库现在还可以从重复组中选择唯一条目,以便更快地审查和清理。提交:[0992170](https://github.com/shuaiplus/nodewarden/commit/099217062a4cb3a3caacce7513354bb388e8d76c)、[39d9df7](https://github.com/shuaiplus/nodewarden/commit/39d9df78ea324fb2d1509221606408b1bb610118)。
1. **新增五种界面语言。** Web 保险库现在提供芬兰语、德语、法语、意大利语和瑞典语,支持的界面语言扩展至十种。提交:[dd90d7b](https://github.com/shuaiplus/nodewarden/commit/dd90d7b8b88a8a49ff1423bb36abb4eeb8f2f329)、[9caa064](https://github.com/shuaiplus/nodewarden/commit/9caa0644888c25db835f8c5c93b8341ed80a42fe)。
### 改进
1. **通行密钥解锁和 Bitwarden 客户端兼容性。** 账户通行密钥现在可以直接解锁 Web 保险库;同时,对受支持 FIDO2 来源、Worker 路由的 fill-assist、Android asset-links 检查和 Web 启动路径的识别更加一致。提交:[8c65cb2](https://github.com/shuaiplus/nodewarden/commit/8c65cb2e80c6e5454fb53dbd7ea45cb83bf88ef7)、[db31792](https://github.com/shuaiplus/nodewarden/commit/db31792cefc0f21fd543faf21407107a53b8dac2)。
1. **更清晰的离线和 Send 使用体验。** 应用现在会明确提示离线运行状态,Send 页面采用了更好的日期格式,关闭对话框时会在动画期间保留标题,避免视觉闪烁。提交:[aae614a](https://github.com/shuaiplus/nodewarden/commit/aae614a079b5fa151e2bb98506f1b4fceac29072)、[04cb475](https://github.com/shuaiplus/nodewarden/commit/04cb4759358b85029e3e32a5cab6ca39cbbef744)、[525b773](https://github.com/shuaiplus/nodewarden/commit/525b773cf4799913ac24e34857348e3aa176608b)。
1. **简化新设备登录。** 不再强制执行新设备验证,能够正常完成身份验证的设备无需额外登录步骤。提交:[14dff8e](https://github.com/shuaiplus/nodewarden/commit/14dff8ee6a59b741d86a42b25451116b120ac404)。
### 修复
1. **备份目标的 SSRF 防护。** 备份目标校验现在会拒绝 IPv6 回环地址,堵住了可能绕过回环主机保护的路径。提交:[3c581d1](https://github.com/shuaiplus/nodewarden/commit/3c581d1fb1d92da9e00d3ff139c46f080462e6e8)。
1. **敏感管理员操作需要主密码。** 管理员操作和设备擦除操作现在需要确认主密码,降低无人值守或会话遭入侵时的影响。提交:[fa611dc](https://github.com/shuaiplus/nodewarden/commit/fa611dc8430fc80744662feaaf3912341d5b63f2)。
# v1.7.3 - Stronger Two-Step Login and Client Compatibility
### Added
1. **YubiKey OTP and passkey two-step login.** NodeWarden now supports YubiKey OTP as a managed two-factor provider and adds passkey-based two-factor authentication, including setup screens, WebAuthn fallback connector handling, multi-provider login prompts, and safer WebAuthn response normalization. Commits: [f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745), [c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93), [e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d), [d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d).
1. **Bitwarden extended vault item types.** Vault items now cover bank accounts, driver's licenses, and passports in addition to the existing login, card, identity, secure note, and SSH key flows. The web vault can create, display, decrypt, import, and export these item types with clearer sidebar icons. Commits: [109593d](https://github.com/shuaiplus/nodewarden/commit/109593d), [9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b).
1. **More Bitwarden client compatibility endpoints.** Added device verification settings, device registration routes, admin auth-request compatibility, fill-assist alignment, and push relay installation handling so more official Bitwarden client flows receive expected responses. Unsupported email verification and KDF routes now return explicit unsupported responses instead of ambiguous failures. Commits: [e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84), [8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b), [f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233), [56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f), [fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff), [cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555).
### Improved
1. **TOTP QR scanning and Bitwarden-compatible TOTP behavior.** Uploading TOTP QR codes now falls back to `jsQR` when browser `BarcodeDetector` support is incomplete, handles transparent PNGs correctly, validates uploaded QR images, and throttles camera fallback decoding to reduce CPU usage. TOTP storage and decryption behavior is also aligned more closely with Bitwarden clients. Commits: [b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b), [d024798](https://github.com/shuaiplus/nodewarden/commit/d024798), [73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b), [6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220), [8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210).
1. **Settings, device management, and localization polish.** Device management now lives inside Settings with updated navigation, the two-step provider UI is more responsive, and new settings, audit-log, and validation messages are localized across supported languages. This makes the security settings area easier to scan on desktop and mobile. Commits: [c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6), [062c966](https://github.com/shuaiplus/nodewarden/commit/062c966), [12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e), [c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f), [01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627).
1. **Encrypted Send password visibility and editing.** Password-protected Sends now show a lock indicator in the list, display masked password dots when editing an existing protected Send, and provide a compact trash-icon control for removing the stored password. This makes password state visible without exposing the password itself. Commits: [a870142](https://github.com/shuaiplus/nodewarden/commit/a870142), [ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e).
1. **Website icon behavior and workflow maintenance.** Website icons are now always available without the old `WEBSITE_ICONS_ENABLED` environment toggle, while icon requests keep privacy protections. The global-domains sync workflow also validates its ref before running. Commits: [57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9), [c643874](https://github.com/shuaiplus/nodewarden/commit/c643874), [680e287](https://github.com/shuaiplus/nodewarden/commit/680e287).
### Fixed
1. **Authentication, token, and rate-limit hardening.** API keys are stored as hashes, password rotation and JWT handling were tightened, user cache invalidates on token handling, remembered 2FA tokens survive a bad password attempt, the current access-token session is revoked correctly, and known rate-limit reset bypasses were closed. Commits: [1545881](https://github.com/shuaiplus/nodewarden/commit/1545881), [439683d](https://github.com/shuaiplus/nodewarden/commit/439683d), [60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298), [d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe), [1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f), [2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc), [ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be).
1. **User data isolation and request validation.** Storage reads are scoped by user, Send file routes gate access more strictly, anonymous notification hub requests are validated, and multipart backup/upload requests now have caps. This reduces the chance of cross-user data reads or oversized requests reaching deeper handlers. Commits: [baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699), [8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1), [23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd), [5142846](https://github.com/shuaiplus/nodewarden/commit/5142846).
1. **Backup, restore, and download safety.** Remote backup deletes are verified, archives and backup blobs are validated before use, destination secrets are redacted from settings responses, backup/download token flows are harder to misuse, and WebAuthn credential purpose survives backup export/import. A backup uploader redirect guard was also reverted to restore compatible remote behavior. Commits: [0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0), [00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0), [5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b), [cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830), [f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a), [a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb).
1. **Import compatibility and encrypted-field validation.** Imports now validate payload structure and ZIP entries before processing, and plaintext FIDO2 credential, SSH key, and password-history fields are rejected instead of being silently accepted and later dropped. This makes failed imports clearer and protects encrypted vault fields from incompatible plaintext data. Commits: [cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704), [1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4).
1. **Admin, audit, WebAuthn, and backup endpoint edge cases.** Admin audit-clears are recorded, passkey 2FA status is reported correctly, WebAuthn extension origins are constrained, and auth-request plus backup endpoint checks were tightened around sensitive flows. Commits: [d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19), [ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8), [7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5).
---
### 新增
1. **YubiKey OTP 和通行密钥两步登录。** NodeWarden 现在支持将 YubiKey OTP 作为可管理的双因素提供商,并新增基于通行密钥的双因素认证,包含设置界面、WebAuthn 备用连接器处理、多提供商登录提示,以及更安全的 WebAuthn 响应规范化。提交:[f63b745](https://github.com/shuaiplus/nodewarden/commit/f63b745)、[c019c93](https://github.com/shuaiplus/nodewarden/commit/c019c93)、[e73ae3d](https://github.com/shuaiplus/nodewarden/commit/e73ae3d)、[d8cc88d](https://github.com/shuaiplus/nodewarden/commit/d8cc88d)。
1. **Bitwarden 扩展保险库条目类型。** 除现有登录、银行卡、身份、安全笔记和 SSH 密钥流程外,保险库条目现在还覆盖银行账户、驾驶证和护照。Web 保险库可以创建、展示、解密、导入和导出这些条目类型,并提供更清晰的侧边栏图标。提交:[109593d](https://github.com/shuaiplus/nodewarden/commit/109593d)、[9de0d3b](https://github.com/shuaiplus/nodewarden/commit/9de0d3b)。
1. **更多 Bitwarden 客户端兼容端点。** 新增设备验证设置、设备注册路由、管理员认证请求兼容、fill-assist 对齐和推送中继安装处理,让更多官方 Bitwarden 客户端流程能获得预期响应。不支持的邮箱验证和 KDF 路由现在会返回明确的不支持响应,而不是含糊失败。提交:[e376a84](https://github.com/shuaiplus/nodewarden/commit/e376a84)、[8b2f98b](https://github.com/shuaiplus/nodewarden/commit/8b2f98b)、[f0e5233](https://github.com/shuaiplus/nodewarden/commit/f0e5233)、[56b301f](https://github.com/shuaiplus/nodewarden/commit/56b301f)、[fd46dff](https://github.com/shuaiplus/nodewarden/commit/fd46dff)、[cde4555](https://github.com/shuaiplus/nodewarden/commit/cde4555)。
### 改进
1. **TOTP 二维码扫描和 Bitwarden 兼容 TOTP 行为。** 上传 TOTP 二维码时,如果浏览器 `BarcodeDetector` 支持不完整,现在会回退到 `jsQR`,并正确处理透明 PNG、校验上传的二维码图片、限制摄像头回退解码频率以降低 CPU 占用。TOTP 的存储和解密行为也更贴近 Bitwarden 客户端。提交:[b0a679b](https://github.com/shuaiplus/nodewarden/commit/b0a679b)、[d024798](https://github.com/shuaiplus/nodewarden/commit/d024798)、[73bbe8b](https://github.com/shuaiplus/nodewarden/commit/73bbe8b)、[6e72220](https://github.com/shuaiplus/nodewarden/commit/6e72220)、[8a5b210](https://github.com/shuaiplus/nodewarden/commit/8a5b210)。
1. **设置、设备管理和本地化打磨。** 设备管理现在整合进设置页并更新了导航,两步验证提供商界面在响应式布局下更顺手,新的设置、审计日志和校验消息也补齐了受支持语言的本地化。安全设置区域在桌面和移动端都更容易浏览。提交:[c7eb6c6](https://github.com/shuaiplus/nodewarden/commit/c7eb6c6)、[062c966](https://github.com/shuaiplus/nodewarden/commit/062c966)、[12af18e](https://github.com/shuaiplus/nodewarden/commit/12af18e)、[c53d71f](https://github.com/shuaiplus/nodewarden/commit/c53d71f)、[01ff627](https://github.com/shuaiplus/nodewarden/commit/01ff627)。
1. **加密 Send 的密码状态展示与编辑。** 受密码保护的 Send 现在会在列表中显示锁定标记,编辑已有受保护 Send 时会显示密码掩码圆点,并提供紧凑的垃圾桶图标用于移除已保存密码。这样可以看清密码状态,同时不暴露密码本身。提交:[a870142](https://github.com/shuaiplus/nodewarden/commit/a870142)、[ebc8e8e](https://github.com/shuaiplus/nodewarden/commit/ebc8e8e)。
1. **网站图标行为和工作流维护。** 网站图标现在无需旧的 `WEBSITE_ICONS_ENABLED` 环境开关即可始终可用,同时图标请求仍保留隐私保护。global-domains 同步工作流也会在运行前校验引用。提交:[57c5ef9](https://github.com/shuaiplus/nodewarden/commit/57c5ef9)、[c643874](https://github.com/shuaiplus/nodewarden/commit/c643874)、[680e287](https://github.com/shuaiplus/nodewarden/commit/680e287)。
### 修复
1. **认证、令牌和速率限制加固。** API key 现在以哈希形式存储,密码轮换和 JWT 处理更严格,令牌处理时会使用户缓存失效,错误密码不会丢失已记住的 2FA token,当前访问令牌会被正确撤销,并关闭了已知的速率限制重置绕过路径。提交:[1545881](https://github.com/shuaiplus/nodewarden/commit/1545881)、[439683d](https://github.com/shuaiplus/nodewarden/commit/439683d)、[60dd298](https://github.com/shuaiplus/nodewarden/commit/60dd298)、[d9a36fe](https://github.com/shuaiplus/nodewarden/commit/d9a36fe)、[1bad32f](https://github.com/shuaiplus/nodewarden/commit/1bad32f)、[2df43cc](https://github.com/shuaiplus/nodewarden/commit/2df43cc)、[ae168be](https://github.com/shuaiplus/nodewarden/commit/ae168be)。
1. **用户数据隔离和请求校验。** 存储读取现在按用户限定范围,Send 文件路由更严格地拦截访问,匿名通知 hub 请求会被校验,并且多段备份/上传请求增加了上限。这降低了跨用户数据读取或超大请求进入深层处理器的风险。提交:[baf5699](https://github.com/shuaiplus/nodewarden/commit/baf5699)、[8c481a1](https://github.com/shuaiplus/nodewarden/commit/8c481a1)、[23c53bd](https://github.com/shuaiplus/nodewarden/commit/23c53bd)、[5142846](https://github.com/shuaiplus/nodewarden/commit/5142846)。
1. **备份、恢复和下载安全性。** 远端备份删除现在会被验证,归档和备份 blob 使用前会校验,目标配置里的密钥会在设置响应中脱敏,备份/下载令牌流程更难被误用,WebAuthn 凭据用途也会在备份导出/导入中保留。备份上传器的重定向防护也已回退,以恢复兼容的远端行为。提交:[0cef6a0](https://github.com/shuaiplus/nodewarden/commit/0cef6a0)、[00e0ec0](https://github.com/shuaiplus/nodewarden/commit/00e0ec0)、[5c8f01b](https://github.com/shuaiplus/nodewarden/commit/5c8f01b)、[cc4a830](https://github.com/shuaiplus/nodewarden/commit/cc4a830)、[f532d3a](https://github.com/shuaiplus/nodewarden/commit/f532d3a)、[a366acb](https://github.com/shuaiplus/nodewarden/commit/a366acb)。
1. **导入兼容性和加密字段校验。** 导入流程现在会在处理前校验 payload 结构和 ZIP 条目,明文 FIDO2 凭据、SSH 密钥和密码历史字段会被拒绝,而不是先被静默接受再在响应时丢弃。这让失败导入更清楚,也保护加密保险库字段不接收不兼容的明文数据。提交:[cf14704](https://github.com/shuaiplus/nodewarden/commit/cf14704)、[1ec6ed4](https://github.com/shuaiplus/nodewarden/commit/1ec6ed4)。
1. **管理员、审计、WebAuthn 和备份端点边界情况。** 管理员清空审计日志会被记录,通行密钥 2FA 状态会正确上报,WebAuthn 扩展来源会受到限制,并且认证请求与备份端点围绕敏感流程的校验也更严格。提交:[d028b19](https://github.com/shuaiplus/nodewarden/commit/d028b19)、[ace00e8](https://github.com/shuaiplus/nodewarden/commit/ace00e8)、[7ac6ae5](https://github.com/shuaiplus/nodewarden/commit/7ac6ae5)。
---
# v1.7.2 - New Backup Providers, WebAuthn PRF, and UI Polish
### Added
1. **Three new S3-compatible backup providers: Backblaze B2, Cloudflare R2, and Tigris.** Each new destination comes with detailed provider-specific recommendations, storage-class guidance, and localization strings across all five supported languages. You can now back up to more services without custom scripting. Commits: [1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e), [c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b), [ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698).
2. **WebAuthn PRF (pseudorandom function) extension support.** Credential creation and assertion now pass browser-compatible PRF extension requests, support excluding PRF extensions where the client doesn't need them, and handle the underlying passkey operations more robustly. This improves WebAuthn compatibility with modern browsers and password managers that rely on PRF for per-credential keys. Commits: [8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b), [31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19), [6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835), [bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b).
3. **Backup import locking and checksum verification.** Restoring a full backup now acquires an exclusive lock so concurrent imports cannot collide, and the importer verifies file checksums before applying the data. This makes disaster recovery safer when multiple admins might trigger restores. Commit: [e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec).
4. **Fullscreen layout toggle.** The web vault can now switch to fullscreen mode with a dedicated toggle button, with corresponding localization updates. Useful for kiosk-mode or presentation setups. Commit: [d722815](https://github.com/shuaiplus/nodewarden/commit/d722815).
5. **Fill-assist API handlers.** NodeWarden now implements Bitwarden-compatible credential fill-assist endpoints, letting clients fetch credentials inline via the new `POST /fill-assist` route. Device response types are also updated to include the fields needed by the fill-assist flow. Commit: [e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4).
6. **Device selection and removal in SecurityDevicesPage.** The security devices panel now supports selecting individual trusted devices and removing them directly from the web UI, so you no longer need to use the API to revoke a specific device. Commit: [a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a).
7. **Delete invalid organization invitations.** Admins can now detect and remove dangling or invalid invitations from the admin panel, helping keep the invitation list clean. The API also renamed `revokeInvite` to `deleteInvite` for clearer semantics. Commits: [0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19), [f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3).
8. **validFolderIds support in cipher responses.** Sync and cipher responses now include a `validFolderIds` field so clients can distinguish real folders from orphaned references. The folder repository also validates folder existence more strictly. Commit: [82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e).
9. **Pending auth request loading state.** The pending login-request panel shows a refreshing indicator while fetching or updating the request list, providing clearer feedback during auth request workflows. Commit: [4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b).
### Improved
1. **Enhanced Bitwarden CSV import with custom field and multiline support.** The CSV parser now recognizes custom fields and restores their metadata correctly during import. It also preserves multiline values such as SSH private keys—previously, any line without a `: ` delimiter was silently dropped, truncating private keys to the first line. Text fields containing newlines now survive a full export-import round-trip. Commits: [5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e), [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
2. **Consolidated security devices UI.** Device management and authorized devices sections are merged into a single coherent card on SecurityDevicesPage, and the pending-auth-requests panel has been removed from the general SettingsPage to reduce clutter. The device list also includes improved selection controls. Commit: [c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b).
3. **Refined app-shell styles and dark mode consistency.** Removed redundant global styles, cleaned up shell component spacing, and improved dark-mode visual consistency across the header, sidebar, and main content areas. Commit: [1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6).
4. **Backup and restore error messages across all locales.** New error strings for backup/restore edge cases—lock failures, checksum mismatches, missing files—are now localized in all five supported languages (en, es, ru, zh-CN, zh-TW), with improved UI prompts for backup browser refresh scenarios. Commit: [4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0).
5. **Updated project wiki link and removed obsolete security scripts.** The issue-template wiki link now points to the correct URL, and the old local security scanning scripts and workflows have been removed in favor of GitHub-native security automation (CodeQL, security-extra workflows). Commit: [e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c).
6. **Security automation and dependency hardening.** Added GitHub-native CodeQL and security-extra workflows, overrode a `ws` vulnerability, and upgraded CI actions to pinned major versions (checkout v7, setup-node v6, create-pull-request v8). Dependencies refreshed include TypeScript 6.0, `@types/node` 26, `lucide-preact` 1.x, and many others across npm and GitHub Actions. Commits: [64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7), [32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a), [5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff), [8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca), [5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab), [99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f), [fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae), [c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac).
### Fixed
1. **CSV import truncating multiline field values.** `parseBitwardenCsvFieldLines` previously discarded any line that did not contain a `: ` delimiter, silently dropping SSH private keys and other multiline content to only the first line. The parser now accumulates continuation lines correctly, restoring full private key content through a CSV round-trip. Commit: [68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0).
---
### 新增
1. **三个新的 S3 兼容备份提供商:Backblaze B2、Cloudflare R2 和 Tigris。** 每个新目标都带有详细的提供商建议、存储层级指导和五种语言的本地化字符串,无需额外脚本即可将备份扩展到更多存储服务。提交:[1acc31e](https://github.com/shuaiplus/nodewarden/commit/1acc31e)、[c3dc53b](https://github.com/shuaiplus/nodewarden/commit/c3dc53b)、[ff85698](https://github.com/shuaiplus/nodewarden/commit/ff85698)。
2. **WebAuthn PRF(伪随机函数)扩展支持。** 创建和断言凭证时会传递浏览器兼容的 PRF 扩展请求,支持在不需要时排除 PRF 扩展,并且底层密钥操作更健壮。这改善了与依赖 PRF 做每凭据密钥派生功能的现代浏览器和密码管理器的兼容性。提交:[8942e5b](https://github.com/shuaiplus/nodewarden/commit/8942e5b)、[31cfd19](https://github.com/shuaiplus/nodewarden/commit/31cfd19)、[6a1a835](https://github.com/shuaiplus/nodewarden/commit/6a1a835)、[bf6ac7b](https://github.com/shuaiplus/nodewarden/commit/bf6ac7b)。
3. **备份导入加锁和校验和验证。** 完整恢复备份时现在会获取独占锁,防止并发导入冲突;导入前还会验证文件校验和再应用数据。多管理员可能同时触发恢复时,该机制让灾难恢复更加安全。提交:[e9272ec](https://github.com/shuaiplus/nodewarden/commit/e9272ec)。
4. **全屏布局切换。** Web 保险库现在可以通过专用按钮切换全屏模式,附带对应本地化更新。适合信息亭模式或展示等场景。提交:[d722815](https://github.com/shuaiplus/nodewarden/commit/d722815)。
5. **Fill-assist API 处理器。** NodeWarden 现在实现了与 Bitwarden 兼容的凭据填充辅助端点,客户端可以通过新的 `POST /fill-assist` 路由内联获取凭据。设备响应类型也补上了 fill-assist 流程需要的字段。提交:[e4215b4](https://github.com/shuaiplus/nodewarden/commit/e4215b4)。
6. **安全设备页的设备选择与删除。** 设备面板现在支持在 Web UI 中直接选择单个可信设备并移除,无需通过 API 手动撤销指定设备。提交:[a5ad16a](https://github.com/shuaiplus/nodewarden/commit/a5ad16a)。
7. **删除无效邀请码。** 管理员现在可以在管理面板中检测并删除悬空或无效的邀请,保持邀请列表整洁。API 也将 `revokeInvite` 改名为 `deleteInvite`,语义更清晰。提交:[0d1bb19](https://github.com/shuaiplus/nodewarden/commit/0d1bb19)、[f82dcc3](https://github.com/shuaiplus/nodewarden/commit/f82dcc3)。
8. **密码条目响应增加 validFolderIds。** 同步和密码条目响应现在包含 `validFolderIds` 字段,方便客户端区分真实文件夹和孤立引用;文件夹存储也加强了对文件夹存在性的校验。提交:[82f968e](https://github.com/shuaiplus/nodewarden/commit/82f968e)。
9. **待处理认证请求的加载状态。** 待处理的登录请求面板现在会在获取或更新请求列表时显示刷新指示器,为认证请求操作提供更清晰的反馈。提交:[4378e1b](https://github.com/shuaiplus/nodewarden/commit/4378e1b)。
### 改进
1. **增强的 Bitwarden CSV 导入——自定义字段和多行支持。** CSV 解析器现在可以识别自定义字段并在导入时正确恢复其元数据。同时保留了 SSH 私钥等多行值——之前任何不带 `: ` 分隔符的行都会被丢弃,导致私钥只保留第一行。包含换行符的文本字段现在可以完整通过导出-导入周期。提交:[5eeaf4e](https://github.com/shuaiplus/nodewarden/commit/5eeaf4e)、[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
2. **整合安全设备界面。** 设备管理和已授权设备两个部分合并为 SecurityDevicesPage 上的一个统一卡片;待处理认证请求面板从 SettingsPage 中移除以减少杂乱。设备列表也改进了选择操作。提交:[c694f1b](https://github.com/shuaiplus/nodewarden/commit/c694f1b)。
3. **精简应用外壳样式与暗色模式一致性。** 移除了冗余全局样式,清理了外壳组件间距,改善了头部、侧边栏和主内容区在暗色模式下的视觉一致性。提交:[1bfb9a6](https://github.com/shuaiplus/nodewarden/commit/1bfb9a6)。
4. **备份/恢复错误消息全语言本地化。** 备份/恢复边界场景(加锁失败、校验和不匹配、文件缺失)的新错误字符串已在五种支持语言(en、es、ru、zh-CN、zh-TW)中完成本地化,同时改进了备份浏览器刷新场景下的界面提示。提交:[4cd9ad0](https://github.com/shuaiplus/nodewarden/commit/4cd9ad0)。
5. **更新项目 Wiki 链接并移除过时安全脚本。** 议题模板中的 Wiki 链接已指向正确 URL;老旧的本地安全扫描脚本和工作流已移除,改用 GitHub 原生安全自动化(CodeQL、security-extra 工作流)。提交:[e31f82c](https://github.com/shuaiplus/nodewarden/commit/e31f82c)。
6. **安全自动化和依赖加固。** 新增 GitHub 原生 CodeQL 和 security-extra 工作流;覆盖了 `ws` 的已知漏洞;将 CI Action 升级到钉死的主要版本(checkout v7、setup-node v6、create-pull-request v8)。依赖升级包括 TypeScript 6.0、`@types/node` 26、`lucide-preact` 1.x,以及 npm 和 GitHub Actions 的多项更新。提交:[64f26e7](https://github.com/shuaiplus/nodewarden/commit/64f26e7)、[32b3d2a](https://github.com/shuaiplus/nodewarden/commit/32b3d2a)、[5dd9dff](https://github.com/shuaiplus/nodewarden/commit/5dd9dff)、[8d292ca](https://github.com/shuaiplus/nodewarden/commit/8d292ca)、[5bd7dab](https://github.com/shuaiplus/nodewarden/commit/5bd7dab)、[99f2d7f](https://github.com/shuaiplus/nodewarden/commit/99f2d7f)、[fb9a2ae](https://github.com/shuaiplus/nodewarden/commit/fb9a2ae)、[c87e6ac](https://github.com/shuaiplus/nodewarden/commit/c87e6ac)。
### 修复
1. **CSV 导入截断多行字段值。** `parseBitwardenCsvFieldLines` 之前会丢弃任何不包含 `: ` 分隔符的行,导致 SSH 私钥等多行内容被静默截断为仅第一行。解析器现已正确累积后续行,使私钥等完整内容能够通过 CSV 导出-导入周期完好保留。提交:[68c42a0](https://github.com/shuaiplus/nodewarden/commit/68c42a0)。
# v1.7.1 - Security Hardening Update
Thanks to GN998 for responsibly reporting security issues addressed in this release.
### Added
1. **No new user-facing features.** This patch release intentionally focuses on security fixes and defensive hardening rather than new product functionality. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
### Improved
1. **Stronger security defaults.** NodeWarden now applies more conservative handling around sensitive authentication, backup, and file-delivery flows while keeping existing clients compatible. Upgrade is recommended for all deployments. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
### Fixed
1. **High-priority security fixes.** This release closes multiple reported security issues across sensitive server-side flows and response hardening without exposing operational details in the public notes. Commits: [7279668](https://github.com/shuaiplus/nodewarden/commit/7279668), [850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f), [a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c), [23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3).
2. **Security dependency overrides.** Package overrides were added for selected transitive dependencies so installs resolve to patched versions where applicable. Commit: [0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46).
---
### 新增
感谢 GN998 负责任地报告了本次发布中修复的安全问题。
1. **没有新增面向用户的功能。** 本次补丁发布刻意专注于安全修复和防护加固,不包含新的产品功能。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
### 改进
1. **更稳妥的安全默认行为。** NodeWarden 对敏感认证、备份和文件响应流程采用了更保守的处理方式,同时保持现有客户端兼容。建议所有部署尽快升级。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
### 修复
1. **高优先级安全修复。** 本次发布修复了多项已报告的安全问题,覆盖敏感服务端流程和响应加固;公开说明中不会展开可操作的攻击细节。提交:[7279668](https://github.com/shuaiplus/nodewarden/commit/7279668)、[850fe0f](https://github.com/shuaiplus/nodewarden/commit/850fe0f)、[a2a8f1c](https://github.com/shuaiplus/nodewarden/commit/a2a8f1c)、[23b23f3](https://github.com/shuaiplus/nodewarden/commit/23b23f3)。
2. **安全依赖覆盖。** 为部分传递依赖添加了版本覆盖,让安装时尽可能解析到已修复版本。提交:[0daad46](https://github.com/shuaiplus/nodewarden/commit/0daad46)。
# v1.7.0 - Faster Multi-Device Sync, Mobile Push, and a Smoother Vault
### Added
1. **Resource-level realtime sync.** NodeWarden now sends Bitwarden-style notifications for cipher, folder, and Send create, update, and delete events. The web app can refresh only the affected resource instead of reloading the full vault every time, and state-changing operations such as attachment uploads, attachment deletes, public Send access counts, and Send file downloads also emit the right updates. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f), [46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9), [f096681](https://github.com/shuaiplus/nodewarden/commit/f096681).
2. **Bitwarden mobile push relay support.** Devices can now store `push_uuid` and `push_token`, register or unregister through the Bitwarden push relay, and receive mobile push notifications when vault resources change. The database schema includes the new push fields and indexes needed to detect push-capable devices. Commit: [79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9).
3. **Bitwarden CSV export.** The web app can now export a Bitwarden-compatible CSV file alongside the existing JSON, encrypted JSON, and attachment ZIP formats. Multiple login URIs are serialized safely, and non-login item types such as cards, identities, and SSH keys are preserved as clearly as possible in field text. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e).
4. **More duplicate detection modes.** Duplicate search can now compare exact item content, login site plus credentials, username plus password, or password alone. Duplicate groups are color-coded, and the mobile vault UI includes the new duplicate mode selector and improved filter controls. Commits: [7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
5. **S3 addressing style selection.** Backup destinations can now choose between path-style and virtual-hosted-style S3 URLs, improving compatibility with more S3-compatible providers and self-hosted object storage. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
### Improved
1. **Web vault updates feel immediate.** Creating, editing, deleting, archiving, restoring, and moving items; creating or deleting folders; and creating, updating, or deleting Sends now update the local encrypted snapshot, decrypted lists, and revision timestamp directly. This reduces visible lag after successful actions and makes cached vault validation work better with resource-level sync. Commits: [42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b), [045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f).
2. **Better Bitwarden client compatibility.** Profile and sync responses now include fields such as `organizationsNew`, `policiesNew`, and `V2UpgradeToken`; `/api/accounts/keys` supports GET; password change and password verification accept newer `authenticationData` and `unlockData` request shapes; and device routes work with both `/api/devices` and `/devices`. Cipher responses also preserve stored `edit`, `viewPassword`, and `permissions` flags instead of resetting them. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532).
3. **Cleaner mobile and narrow-screen UI.** Topbar controls, network status, theme switching, and lock buttons now share more consistent sizing and styling. The vault list search, sorting, filtering, create button, and bulk selection toolbar are more compact on mobile, and mobile filter menus can switch between all items, favorites, archive, trash, duplicates, types, and folders. Commits: [7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f), [16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22), [cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82), [c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795).
4. **More tolerant TOTP handling.** TOTP codes are grouped more naturally for 5-digit, 6-digit, 8-digit, and other lengths, and the TOTP list no longer overflows narrow screens because of fixed column widths. `otpauth://` parsing is also more tolerant of unusual parameter encoding, with more stable Steam-code detection. Commits: [9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f), [d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2).
5. **Less jumpy network status.** The web app no longer switches offline after one short failed probe. It uses a longer timeout, waits for repeated failures, and lets normal API successes or failures update the network state, reducing false offline unlock fallbacks when the service is reachable but slow. Commit: [b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04).
6. **More complete backups.** Full instance backups now include trusted two-factor device tokens and restore them during import. The importer validates token ownership, device identifiers, expiration times, and duplicates, so remembered two-factor devices can survive a full migration. Commit: [f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7).
### Fixed
1. **Realtime notification correctness.** Resource notification type numbers now match Bitwarden semantics, while NodeWarden-specific device status and backup progress notifications use internal values to avoid conflicts with official Send update types. SignalR MessagePack invocations now include `streamIds`, pending auth request notifications refresh the login request list, and the web app ignores notifications sent by the current device to avoid redundant refreshes. Commits: [fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c), [9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504), [4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0).
2. **Attachment and Send download details.** Public attachment and Send file downloads now include `Content-Disposition` filenames and `X-Content-Type-Options: nosniff`, making browser downloads keep better filenames and reducing content sniffing issues. Attachment delete responses now include both uppercase and lowercase field forms for broader client compatibility. Commit: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b).
3. **Deleted item and bulk action edge cases.** Vault paging now detects deleted items from both database columns and older JSON payload fields, preventing old deleted items from appearing in the normal vault list. Bulk archive skips deleted items, and duplicate detection now uses decrypted password history instead of encrypted stored text. Commits: [add921b](https://github.com/shuaiplus/nodewarden/commit/add921b), [b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f).
4. **Export, dialog, and toast polish.** CSV export now escapes login URIs correctly inside a single CSV cell; some dialog dismissal behavior is more stable; login and unlock success toasts are less noisy; and the toast close button now uses a styled SVG icon. Commits: [b024226](https://github.com/shuaiplus/nodewarden/commit/b024226), [a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e), [8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f), [907126d](https://github.com/shuaiplus/nodewarden/commit/907126d).
5. **S3 backup URL construction.** Virtual-hosted-style backup operations now use the `bucket.endpoint` form for upload, download, delete, and existence checks, while avoiding duplicate bucket names when the endpoint already includes the bucket. Path-style mode keeps the existing `endpoint/bucket` behavior. Commit: [a818316](https://github.com/shuaiplus/nodewarden/commit/a818316).
---
### 新增
1. **资源级实时同步。** NodeWarden 现在会按 Bitwarden 风格发送密码条目、文件夹、Send 的新增、更新和删除通知。Web 端收到通知后可以只刷新受影响的资源,而不是每次都重新同步整个保险库;附件上传、附件删除、公开 Send 访问计数、Send 文件下载等会改变状态的操作,也会触发对应更新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)、[46ba8b9](https://github.com/shuaiplus/nodewarden/commit/46ba8b9)、[f096681](https://github.com/shuaiplus/nodewarden/commit/f096681)。
2. **Bitwarden 移动端推送中继支持。** 设备现在可以保存 `push_uuid``push_token`,通过 Bitwarden push relay 注册或注销,并在保险库资源变化时尝试接收移动端推送。数据库结构也补上了推送字段和索引,用于识别哪些设备可以被推送。提交:[79ed7c9](https://github.com/shuaiplus/nodewarden/commit/79ed7c9)。
3. **Bitwarden CSV 导出。** 除了原有 JSON、加密 JSON 和带附件 ZIP 导出,现在 Web 端可以直接导出 Bitwarden 兼容 CSV。多个登录 URI 会按 CSV 规则安全序列化,卡片、身份、SSH Key 等非登录类型也会尽量保留到字段文本中,方便迁移或人工整理。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)。
4. **更多重复项检测模式。** 重复项现在可以按完全一致、登录站点加凭据、用户名加密码、单独密码等方式判断。重复组会用颜色辅助区分,移动端保险库也补上了重复项模式选择和更完整的筛选入口。提交:[7b3be2c](https://github.com/shuaiplus/nodewarden/commit/7b3be2c)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
5. **S3 地址样式选择。** 远程备份目标现在可以选择 path-style 或 virtual-hosted-style,兼容更多 S3 服务和自建对象存储。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
### 改进
1. **Web 保险库操作反馈更及时。** 创建、编辑、删除、归档、恢复、移动条目,创建或删除文件夹,以及创建、更新、删除 Send 时,前端会直接更新本地加密快照、解密列表和修订时间。这样操作成功后列表更快跟上,也让资源级同步下的本地缓存校验更稳定。提交:[42b765b](https://github.com/shuaiplus/nodewarden/commit/42b765b)、[045b23f](https://github.com/shuaiplus/nodewarden/commit/045b23f)。
2. **Bitwarden 客户端兼容性更好。** 账户资料和同步响应补齐了 `organizationsNew``policiesNew``V2UpgradeToken` 等字段;`/api/accounts/keys` 支持 GET;改密和校验密码接口兼容较新的 `authenticationData``unlockData` 请求结构;设备路由同时兼容 `/api/devices``/devices`。密码条目响应也会保留已存储的 `edit``viewPassword``permissions`,避免跨客户端编辑时权限标记被重置。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[f9fe532](https://github.com/shuaiplus/nodewarden/commit/f9fe532)。
3. **移动端和小屏界面更顺手。** 顶部栏按钮、网络状态、主题切换、锁定按钮的尺寸和样式更统一。保险库列表里的搜索、排序、筛选、创建按钮和批量选择工具栏在移动端更紧凑;移动筛选菜单可以直接切换全部、收藏、归档、回收站、重复项、类型和文件夹。提交:[7e0406f](https://github.com/shuaiplus/nodewarden/commit/7e0406f)、[16bde22](https://github.com/shuaiplus/nodewarden/commit/16bde22)、[cd2ec82](https://github.com/shuaiplus/nodewarden/commit/cd2ec82)、[c1f5795](https://github.com/shuaiplus/nodewarden/commit/c1f5795)。
4. **TOTP 展示和解析更稳。** 验证码会按 5 位、6 位、8 位等不同长度更自然地分组,列表在窄屏下不会再被固定列宽撑破。`otpauth://` 解析也更能容忍特殊参数编码,Steam 类验证码识别更稳定。提交:[9e0908f](https://github.com/shuaiplus/nodewarden/commit/9e0908f)、[d5c2ab2](https://github.com/shuaiplus/nodewarden/commit/d5c2ab2)。
5. **网络状态不再过度敏感。** Web 端不会因为一次短暂探测失败就立刻判定离线,而是延长探测超时并等待连续失败;普通 API 请求成功或失败也会反向更新网络状态。在线但网络较慢时,不容易误进入离线解锁流程。提交:[b4dfb04](https://github.com/shuaiplus/nodewarden/commit/b4dfb04)。
6. **备份内容更完整。** 完整实例备份现在会导出和还原可信二步验证设备令牌。导入时会校验令牌所属用户、设备标识、过期时间和重复项,让“记住此设备”的二步验证状态在完整迁移后也能保留下来。提交:[f6169b7](https://github.com/shuaiplus/nodewarden/commit/f6169b7)。
### 修复
1. **实时通知类型和刷新逻辑。** 资源通知的类型编号调整为与 Bitwarden 官方语义一致,NodeWarden 自定义的设备状态和备份进度通知改用内部编号,避免和官方 Send 更新类型冲突。SignalR MessagePack 调用补齐了 `streamIds`,认证请求通知会刷新待处理登录请求列表,Web 端也会忽略当前设备自己发出的通知,避免重复刷新。提交:[fe0c66c](https://github.com/shuaiplus/nodewarden/commit/fe0c66c)、[9a21504](https://github.com/shuaiplus/nodewarden/commit/9a21504)、[4900de0](https://github.com/shuaiplus/nodewarden/commit/4900de0)。
2. **附件和 Send 文件下载细节。** 公开附件和 Send 文件下载现在会带上 `Content-Disposition` 文件名和 `X-Content-Type-Options: nosniff`,浏览器保存文件时更接近原文件名,也减少类型嗅探问题。删除附件的响应同时提供大小写两套字段,兼容不同客户端读取方式。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)。
3. **已删除条目和批量操作边界。** 保险库分页查询现在会同时识别数据库列和历史 JSON 数据里的删除时间,避免旧数据中已删除条目出现在正常列表。批量归档会跳过已删除条目,重复项判断也会使用已解密的密码历史,避免加密文本影响结果。提交:[add921b](https://github.com/shuaiplus/nodewarden/commit/add921b)、[b444c0f](https://github.com/shuaiplus/nodewarden/commit/b444c0f)。
4. **导出、弹窗和提示细节。** CSV 导出中的登录 URI 会按单行 CSV 单元格正确转义;部分弹窗关闭行为更稳定;登录或解锁成功后的 toast 更克制,避免重复提示;toast 关闭按钮换成了 SVG 图标并调整了样式。提交:[b024226](https://github.com/shuaiplus/nodewarden/commit/b024226)、[a06cb0e](https://github.com/shuaiplus/nodewarden/commit/a06cb0e)、[8f2704f](https://github.com/shuaiplus/nodewarden/commit/8f2704f)、[907126d](https://github.com/shuaiplus/nodewarden/commit/907126d)。
5. **S3 备份地址拼接。** 选择 virtual-hosted-style 时,备份上传、下载、删除和存在性检查会使用 `bucket.endpoint` 形式;如果 endpoint 已经带有 bucket,也不会重复拼接 bucket。path-style 仍保持原有 `endpoint/bucket` 形式。提交:[a818316](https://github.com/shuaiplus/nodewarden/commit/a818316)。
+17 -1
View File
@@ -31,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,
@@ -132,6 +132,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
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);
@@ -228,9 +233,20 @@ 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,
+1656 -1108
View File
File diff suppressed because it is too large Load Diff
+33 -20
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.7.0",
"version": "1.8.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -15,6 +15,11 @@
"domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"deploy": "wrangler deploy",
"deploy: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"
@@ -42,28 +47,36 @@
}
}
},
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0",
"sharp": "0.35.0"
},
"devDependencies": {
"@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": "^4.20260630.1",
"@preact/preset-vite": "^2.10.6",
"@types/node": "^26.1.2",
"autoprefixer": "^10.5.4",
"opencc-js": "^1.4.1",
"postcss": "^8.5.25",
"tailwindcss": "^3.4.19",
"tsx": "^4.23.1",
"typescript": "^6.0.3",
"vite": "^8.2.0",
"wrangler": "^4.116.0"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.34",
"fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.28.0",
"preact": "^10.29.7",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
"wouter": "^3.10.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildConfigResponse } from '../src/config-response';
test('config enables the official Bitwarden desktop settings dialog', () => {
const body = buildConfigResponse('https://vault.example.test');
assert.equal(body.featureStates['desktop-ui-settings-dialog'], true);
assert.equal(body.environment.vault, 'https://vault.example.test');
assert.equal(body.object, 'config');
});
+5
View File
@@ -13,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) {
@@ -0,0 +1,128 @@
import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts';
import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts';
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function createUserRow(apiKey) {
return {
id: 'user-1',
email: 'user@example.com',
name: 'User',
master_password_hint: null,
master_password_hash: 'master-proof',
key: 'wrapped-user-key',
private_key: null,
public_key: null,
kdf_type: 0,
kdf_iterations: 600000,
kdf_memory: null,
kdf_parallelism: null,
security_stamp: 'security-stamp-original',
role: 'user',
status: 'active',
verify_devices: 0,
totp_secret: null,
totp_recovery_code: null,
yubikey_key1: null,
yubikey_key2: null,
yubikey_key3: null,
yubikey_key4: null,
yubikey_key5: null,
yubikey_nfc: 0,
api_key: apiKey,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
};
}
function createDb(apiKey) {
const state = {
user: createUserRow(apiKey),
userWrites: 0,
refreshDeletes: 0,
auditActions: [],
};
const db = {
prepare(sql) {
let bindings = [];
const statement = {
bind(...values) {
bindings = values;
return statement;
},
async first() {
if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user };
return null;
},
async all() {
return { results: [] };
},
async run() {
if (/INSERT INTO users\(/i.test(sql)) {
state.userWrites += 1;
state.user.security_stamp = bindings[12];
state.user.api_key = bindings[24];
state.user.updated_at = bindings[26];
}
if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1;
if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]);
return { meta: { changes: 1 } };
},
};
return statement;
},
async batch(statements) {
return statements.map(() => ({ success: true, meta: { changes: 1 } }));
},
};
return { db, state };
}
function request() {
return new Request('https://nodewarden.example/api/accounts/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash: 'master-proof' }),
});
}
function env(db) {
return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' };
}
const view = createDb('ExistingReadableApiKey1234567');
const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1');
const viewBody = await viewResponse.json();
assert(viewResponse.status === 200, 'Viewing an existing readable API key failed');
assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key');
assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user');
assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens');
assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing');
const rotate = createDb('ExistingReadableApiKey1234567');
const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1');
const rotateBody = await rotateResponse.json();
assert(rotateResponse.status === 200, 'API key rotation failed');
assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key');
assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key');
assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp');
assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens');
assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates');
assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate');
const legacyPlain = 'LegacyHashedApiKey123456789';
const legacy = createDb(await hashApiKey(legacyPlain));
const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1');
assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation');
assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated');
assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating');
const missing = createDb(null);
const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1');
const missingBody = await missingResponse.json();
assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized');
assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted');
console.log('Bitwarden-compatible API key view and rotation semantics: PASS');
@@ -0,0 +1,138 @@
import { unzipSync, zipSync } from 'fflate';
import {
buildBackupArchive,
parseBackupArchive,
validateBackupPayloadContents,
} from '../src/services/backup-archive.ts';
import { importBackupArchiveBytes } from '../src/services/backup-import.ts';
const forbiddenRuntimeTables = [
'devices',
'refresh_tokens',
'auth_requests',
'trusted_two_factor_device_tokens',
'account_passkey_challenges',
'used_attachment_download_tokens',
];
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function sqlTouchesTable(sql, table) {
return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql);
}
function emptyBackupDb(extra = {}) {
return {
config: [],
users: [],
domain_settings: [],
user_revisions: [],
folders: [],
ciphers: [],
attachments: [],
webauthn_credentials: [],
...extra,
};
}
function archiveBytes(db, tableCounts = {}) {
const encoder = new TextEncoder();
return zipSync({
'manifest.json': encoder.encode(JSON.stringify({
formatVersion: 1,
exportedAt: new Date(0).toISOString(),
appVersion: 'test',
storageKind: null,
tableCounts,
includes: { attachments: false },
blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 },
attachmentBlobs: [],
})),
'db.json': encoder.encode(JSON.stringify(db)),
}, { level: 0 });
}
function createD1Mock({ exportMode = false } = {}) {
const preparedSql = [];
const db = {
prepare(sql) {
preparedSql.push(sql);
let bindings = [];
const statement = {
sql,
bind(...values) {
bindings = values;
return statement;
},
async all() {
if (exportMode) return { results: [] };
return { results: [] };
},
async first() {
if (/SELECT sql FROM sqlite_master/i.test(sql)) {
const table = String(bindings[0] || '').trim();
return { sql: `CREATE TABLE ${table} (id TEXT)` };
}
if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 };
if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 };
return null;
},
async run() {
return { meta: { changes: 0 } };
},
};
return statement;
},
async batch(statements) {
return statements.map(() => ({ success: true, meta: { changes: 0 } }));
},
};
return { db, preparedSql };
}
const exportMock = createD1Mock({ exportMode: true });
const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false });
const exportedZip = unzipSync(exported.bytes);
const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json']));
const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json']));
for (const table of forbiddenRuntimeTables) {
assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`);
assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`);
assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`);
}
const legacyDb = emptyBackupDb({
devices: [{ device_identifier: 'device-secret' }],
refresh_tokens: [{ token: 'refresh-secret' }],
auth_requests: [{ access_code: 'approval-secret' }],
trusted_two_factor_device_tokens: [{ token: 'remember-secret' }],
account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }],
used_attachment_download_tokens: [{ token_hash: 'download-secret' }],
});
const legacyArchive = archiveBytes(legacyDb, {
devices: 1,
refresh_tokens: 1,
auth_requests: 1,
trusted_two_factor_device_tokens: 1,
account_passkey_challenges: 1,
used_attachment_download_tokens: 1,
});
const parsedLegacy = parseBackupArchive(legacyArchive);
validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files);
for (const table of forbiddenRuntimeTables) {
assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`);
}
const restoreMock = createD1Mock();
await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false);
for (const table of forbiddenRuntimeTables) {
assert(
!restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)),
`Restore touched forbidden runtime table: ${table}`
);
}
console.log('backup runtime authentication state exclusion: PASS');
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerAccount } from '../webapp/src/lib/api/auth';
import {
requireWebCrypto,
WebCryptoUnavailableError,
} from '../webapp/src/lib/crypto';
const supportedCrypto = {
subtle: {
importKey: () => Promise.reject(new Error('not used by capability checks')),
},
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(globalThis, name, descriptor);
return;
}
delete (globalThis as unknown as Record<string, unknown>)[name];
}
test('Web Crypto guard rejects insecure browser contexts', () => {
assert.throws(
() => requireWebCrypto({ crypto: supportedCrypto, isSecureContext: false }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard rejects secure contexts without SubtleCrypto', () => {
const cryptoWithoutSubtle = {
getRandomValues: <T>(array: T): T => array,
} as unknown as Crypto;
assert.throws(
() => requireWebCrypto({ crypto: cryptoWithoutSubtle, isSecureContext: true }),
WebCryptoUnavailableError
);
});
test('Web Crypto guard accepts a secure supported browser', () => {
assert.equal(
requireWebCrypto({ crypto: supportedCrypto, isSecureContext: true }),
supportedCrypto
);
});
test('registration returns an actionable error without contacting the backend', async () => {
const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
const secureContextDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'isSecureContext');
const fetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
let fetchCalled = false;
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true });
Object.defineProperty(globalThis, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: async () => {
fetchCalled = true;
return new Response(null, { status: 500 });
},
});
try {
const result = await registerAccount({
email: 'first@example.test',
name: 'First Admin',
password: 'correct horse battery staple',
fallbackIterations: 600_000,
});
assert.deepEqual(result, {
ok: false,
message: 'Secure browser cryptography is unavailable. Open NodeWarden over HTTPS in a supported browser.',
});
assert.equal(fetchCalled, false);
} finally {
restoreGlobalProperty('crypto', cryptoDescriptor);
restoreGlobalProperty('isSecureContext', secureContextDescriptor);
restoreGlobalProperty('fetch', fetchDescriptor);
}
});
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import type { Env } from '../src/types';
import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins';
import { applyCors, handleCors } from '../src/utils/response';
const env = {} as Env;
test('only the iframe connector drops anti-framing headers', () => {
const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html');
const connector = applyCors(connectorRequest, new Response('<!doctype html>'), env);
assert.equal(connector.headers.get('X-Frame-Options'), null);
assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/);
assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/);
for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) {
const request = new Request(`https://vault.example.test${path}`);
const response = applyCors(request, new Response('<!doctype html>'), env);
assert.equal(response.headers.get('X-Frame-Options'), 'DENY');
assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/);
}
});
test('official Bitwarden desktop origin receives credentialed CORS', () => {
assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle'));
const preflight = handleCors(new Request('https://vault.example.test/api/sync', {
method: 'OPTIONS',
headers: {
Origin: 'bw-desktop-file://bundle',
'Access-Control-Request-Headers': 'authorization, content-type',
},
}), env);
assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle');
assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true');
});
test('Worker assets preserve exact official connector .html paths', async () => {
for (const configUrl of [
new URL('../wrangler.toml', import.meta.url),
new URL('../wrangler.kv.toml', import.meta.url),
]) {
const config = await readFile(configUrl, 'utf8');
const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || '';
assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m);
}
});
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
buildCredentialData,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveParentChannel,
} from '../webapp/public/webauthn-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
const publicKeyOptions = {
challenge: 'AQID',
allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }],
timeout: 60000,
rpId: 'vault.example.test',
};
test('parses the official desktop/browser V1 connector request', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)),
parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'),
btnText: encodeURIComponent('Read security key'),
btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'),
v: '1',
});
const request = parseConnectorRequest(params);
assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html');
assert.equal(request.parentProtocol, 'file:');
assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions);
assert.equal(request.buttonText, 'Read security key');
assert.equal(request.awaitingText, 'Awaiting security key interaction...');
});
test('keeps V2 parsing compatible with the shared official connector protocol', () => {
const params = new URLSearchParams({
data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })),
parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'),
v: '2',
});
assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions);
});
test('normalizes WebAuthn challenge and allowed credential IDs', () => {
const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions));
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]);
});
test('emits the exact assertion shape consumed by official Bitwarden clients', () => {
const output = JSON.parse(buildCredentialData({
id: 'credential-id',
rawId: Uint8Array.from([1, 2, 3]).buffer,
type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([4, 5]).buffer,
clientDataJSON: Uint8Array.from([6, 7]).buffer,
signature: Uint8Array.from([8, 9]).buffer,
},
}));
assert.deepEqual(output, {
id: 'credential-id',
rawId: 'AQID',
type: 'public-key',
extensions: { appid: false },
response: {
authenticatorData: 'BAU',
clientDataJson: 'Bgc',
signature: 'CAk',
},
});
});
test('accepts legacy file and current official desktop parent origins', () => {
assert.deepEqual(resolveParentChannel({
parentProtocol: 'file:',
parentUrl: 'file:///C:/Bitwarden/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'null',
targetOrigin: 'file:///C:/Bitwarden/index.html',
});
assert.deepEqual(resolveParentChannel({
parentProtocol: 'bw-desktop-file:',
parentUrl: 'bw-desktop-file://bundle/index.html',
}, 'https://vault.example.test'), {
eventOrigin: 'bw-desktop-file://bundle',
targetOrigin: 'bw-desktop-file://bundle/index.html',
});
});
test('accepts configured official extension origins and rejects arbitrary parents', () => {
const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb';
assert.deepEqual(resolveParentChannel({
parentProtocol: 'chrome-extension:',
parentUrl: `${extension}/popup/index.html`,
}, 'https://vault.example.test', [extension]), {
eventOrigin: extension,
targetOrigin: extension,
});
assert.throws(() => resolveParentChannel({
parentProtocol: 'https:',
parentUrl: 'https://attacker.example/frame',
}, 'https://vault.example.test', []), /Untrusted parent/);
});
test('uses the official postMessage message contract and iframe-sized fallback styling', async () => {
const [html, source, viteConfig] = await Promise.all([
readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'),
readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'),
readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'),
]);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /min-height:\s*40px/);
assert.match(html, /background:\s*#2563eb/);
assert.match(source, /post\('info\|ready'\)/);
assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/);
assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/);
assert.match(source, /event\.data === 'stop'/);
assert.match(source, /event\.data === 'start'/);
assert.match(viteConfig, /endsWith\('-connector\.html'\)/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
base64UrlFromBuffer,
buildCallbackUrl,
buildCredentialData,
decodeBase64Utf8,
normalizePublicKeyOptions,
parseConnectorRequest,
resolveMobileCallbackUri,
} from '../webapp/public/webauthn-mobile-connector.js';
function encodeBase64Utf8(value) {
return Buffer.from(value, 'utf8').toString('base64');
}
function v2Search(payload, extra = '') {
return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`;
}
const assertionOptions = {
challenge: 'AQID-v8',
rpId: 'vault.example.com',
timeout: 60000,
userVerification: 'preferred',
allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }],
};
test('parses the current Bitwarden Android V2 connector payload', () => {
const request = parseConnectorRequest(v2Search({
btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions),
headerText: 'Verify your identity', mobile: true,
}, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com');
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(request.headerText, 'Verify your identity');
assert.equal(request.buttonText, 'Authenticate');
assert.equal(request.returnButtonText, 'Return to app');
assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions);
});
test('uses callbackUri only as a signal and never as the redirect target', () => {
const trustedLooking = parseConnectorRequest(v2Search({
callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
const attacker = parseConnectorRequest(v2Search({
callbackUri: 'https://attacker.example/capture', data: assertionOptions,
}).replace('&parent=bitwarden%3A__webauthn-callback', ''));
assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback');
});
test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => {
const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted'));
assert.equal(request.callbackUri, 'bitwarden://webauthn-callback');
});
test('supports Android custom-scheme and official HTTPS App Link callbacks', () => {
const payload = { mobile: true, data: assertionOptions };
const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden'));
const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu');
const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com');
assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback');
assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback');
assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback');
});
test('supports V1 mobile requests and requires a recognized mobile signal', () => {
const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions)));
assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback');
assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null);
assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i);
});
test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => {
assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份');
const original = structuredClone(assertionOptions);
const normalized = normalizePublicKeyOptions(original);
assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]);
assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]);
assert.deepEqual(original, assertionOptions);
});
test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => {
const serialized = JSON.parse(buildCredentialData({
id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key',
getClientExtensionResults: () => ({ appid: false }),
response: {
authenticatorData: Uint8Array.from([3, 4]).buffer,
clientDataJSON: Uint8Array.from([5, 6]).buffer,
signature: Uint8Array.from([7, 8]).buffer,
userHandle: Uint8Array.from([9, 10]).buffer,
},
}));
assert.deepEqual(serialized, {
id: 'credential-id',
rawId: 'AQL_',
type: 'public-key',
extensions: { appid: false },
response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' },
});
assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8');
});
test('encodes success and error callbacks safely', () => {
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D');
assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed');
});
test('HTML matches the fallback connector visual structure', async () => {
const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8');
assert.match(html, /id="webauthn-header"/);
assert.match(html, /id="webauthn-button"/);
assert.match(html, /class="connector-card"/);
assert.match(html, /class="brand"/);
assert.match(html, /class="form"/);
assert.match(html, /class="msg"/);
assert.match(html, /src="\/nodewarden-logo\.svg"/);
assert.match(html, /src="\/webauthn-mobile-connector\.js"/);
assert.match(html, /default-src 'none'/);
});
test('runtime uses Bitwarden-compatible replacement navigation', async () => {
const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8');
assert.match(source, /window\.location\.replace\(uri\)/);
assert.doesNotMatch(source, /location\.assign/);
assert.doesNotMatch(source, /safeCallbackFromPayload/);
});
test('Service Worker keeps connector navigations out of the SPA shell', async () => {
const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8');
assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/);
assert.match(config, /connectorNavigation\(request\)/);
assert.match(config, /WebAuthn connector is unavailable while offline/);
});
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.0';
export const APP_VERSION = '1.8.0';
+4 -3
View File
@@ -9,7 +9,8 @@
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';
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
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,
};
}
+53
View File
@@ -0,0 +1,53 @@
import { LIMITS } from './config/limits';
function buildIconServiceTemplate(origin: string): string {
return `${origin}/icons/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
export function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
suppressOnboardingInterstitials: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'desktop-ui-settings-dialog': true,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
+24 -7
View File
@@ -3,12 +3,14 @@
// Access token lifetime in seconds.
// 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 30 * 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,
@@ -62,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,
@@ -148,7 +156,9 @@
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.4.1',
// Vaultwarden 1.37.0 advertises 2026.6.0 after aligning its API response
// with the response contract required by Bitwarden 2026.7.x clients.
bitwardenServerVersion: '2026.6.0',
// Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
@@ -156,3 +166,10 @@
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;
}
+25 -14
View File
@@ -19,7 +19,7 @@ import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
}
let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
@@ -230,21 +231,30 @@ export class BackupTransferRunner {
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await this.touchJob(token);
await executeConfiguredBackup(
this.env,
storage,
null,
'scheduled',
destination.id,
() => this.touchJob(token)
);
completed += 1;
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: {
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
replaceExisting,
!checksumOk,
body.auditMetadata || null,
targetDeviceIdentifier
targetDeviceIdentifier,
() => this.touchJob(token)
);
return new Response(JSON.stringify(result.result), {
@@ -361,7 +372,7 @@ export class BackupTransferRunner {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) {
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid');
}
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
@@ -387,7 +398,7 @@ export class BackupTransferRunner {
const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim())
.filter(Boolean)
.filter(isSafeBackupAttachmentBlobName)
));
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid');
@@ -435,7 +446,7 @@ export class BackupTransferRunner {
for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim();
if (!blobName) {
if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid');
}
+306 -1
View File
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey';
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
import {
accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus,
@@ -29,8 +29,10 @@ import {
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> : {};
@@ -81,6 +83,43 @@ 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;
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
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;
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
});
}
export async function buildTwoFactorPasskeyAssertionOptions(
request: Request,
env: Env,
storage: StorageService,
user: User
): Promise<Record<string, unknown> | null> {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (!credentials.length) return null;
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'discouraged',
timeout: 60000,
});
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
return options as unknown as Record<string, unknown>;
}
export async function assertTwoFactorPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
user: User,
deviceResponse: unknown
): Promise<AccountPasskeyCredential> {
const response = normalizeAuthenticationResponse(deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
throw new Error('Passkey is not registered for two-step login');
}
const challenge = readAuthenticationChallenge(response);
if (!challenge) {
throw new Error('Passkey assertion challenge is missing');
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorAuthentication',
user.id,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: false,
});
if (!verification.verified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return credential;
}
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'discouraged',
requireResidentKey: false,
userVerification: 'discouraged',
},
});
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
return jsonResponse(options);
}
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const challenge = readRegistrationChallenge(registrationResponse);
if (!challenge) {
return errorResponse('Passkey challenge is missing', 400);
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorCreate',
userId,
Date.now()
);
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserPresence: true,
requireUserVerification: false,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const transports = normalizeTransports(registrationResponse.response.transports);
await storage.saveAccountPasskeyCredential({
id: generateUUID(),
userId,
purpose: 'twoFactor',
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
supportsPrf: false,
createdAt: now,
updatedAt: now,
});
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = now;
await storage.saveUser(user);
}
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.enable',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: null,
metadata: auditRequestMetadata(request),
});
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const requestedId = Number(body.id ?? body.Id);
if (!Number.isInteger(requestedId) || requestedId <= 0) {
return errorResponse('Invalid key id', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length < 2) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const credential = credentials[requestedId - 1];
if (!credential) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.delete',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
const credential: AccountPasskeyCredential = {
id: generateUUID(),
userId,
purpose: 'login',
name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
+512 -103
View File
@@ -1,4 +1,4 @@
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
import { Env, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -6,12 +6,21 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { isStoredApiKeyHash } from '../utils/api-key';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response';
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import {
getYubicoCredentials,
initializeYubicoCredentialsOnce,
replaceYubicoCredentials,
} from '../services/yubico-config';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -36,6 +45,9 @@ function looksLikeEncString(value: string): boolean {
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type !== 0 && type !== 1) {
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
}
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
@@ -149,10 +161,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
@@ -193,6 +204,10 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
return typeof current === 'number' ? current : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -241,9 +256,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) {
const message = unsafe === 'missing'
? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400);
}
@@ -321,10 +334,18 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(),
role: 'user',
status: 'active',
verifyDevices: true,
verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null,
totpRecoveryCode: null,
apiKey: null,
yubikeyKey1: null,
yubikeyKey2: null,
yubikeyKey3: null,
yubikeyKey4: null,
yubikeyKey5: null,
yubikeyNfc: false,
// Bitwarden creates a readable personal API key with the account. It is
// returned only after fresh user verification and is excluded from backups.
apiKey: randomStringAlphanum(LIMITS.auth.clientSecretLength),
createdAt: now,
updatedAt: now,
};
@@ -353,20 +374,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403);
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try {
await storage.createUser(user);
} catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
console.error('Registration failed after invite reservation:', error);
throw error;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
try {
const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
if (!assigned) {
console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
}
await writeAuditEvent(storage, {
@@ -403,7 +435,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
}
const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute,
60
@@ -425,7 +457,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
);
}
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60
@@ -505,51 +537,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}
// PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
// Log the attempt for audit purposes, but do not change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// GET /api/accounts/keys
@@ -664,42 +676,100 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
const newMasterPasswordHash =
body.newMasterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400);
const hasAuthenticationData = isRecord(body.authenticationData);
const hasUnlockData = isRecord(body.unlockData);
if (hasAuthenticationData !== hasUnlockData) {
return errorResponse('authenticationData and unlockData must be provided together', 400);
}
const nextKey =
body.newKey ||
body.key ||
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']);
const legacyMasterPasswordHash = typeof body.newMasterPasswordHash === 'string'
? body.newMasterPasswordHash.trim()
: '';
const legacyKey = typeof body.newKey === 'string' && body.newKey.trim()
? body.newKey.trim()
: typeof body.key === 'string'
? body.key.trim()
: '';
let newMasterPasswordHash: string;
let nextKey: string;
if (hasAuthenticationData && hasUnlockData) {
newMasterPasswordHash = readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']).trim();
nextKey = readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']).trim();
if (!newMasterPasswordHash || !nextKey) {
return errorResponse('authenticationData and unlockData are incomplete', 400);
}
const authKdf = readNestedNumber(body, ['authenticationData', 'kdf', 'kdfType']);
const authIterations = readNestedNumber(body, ['authenticationData', 'kdf', 'iterations']);
const authMemory = readNestedNumber(body, ['authenticationData', 'kdf', 'memory']);
const authParallelism = readNestedNumber(body, ['authenticationData', 'kdf', 'parallelism']);
const unlockKdf = readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']);
const unlockIterations = readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const unlockMemory = readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const unlockParallelism = readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const authSalt = readNestedString(body, ['authenticationData', 'salt']);
const unlockSalt = readNestedString(body, ['unlockData', 'salt']);
const expectedSalt = user.email.trim().toLowerCase();
if (authKdf === undefined || authIterations === undefined || unlockKdf === undefined || unlockIterations === undefined) {
return errorResponse('authenticationData and unlockData must include KDF settings', 400);
}
if (
authKdf !== unlockKdf ||
authIterations !== unlockIterations ||
authMemory !== unlockMemory ||
authParallelism !== unlockParallelism
) {
return errorResponse('authenticationData and unlockData must use the same KDF settings', 400);
}
if (!authSalt || authSalt !== unlockSalt || authSalt !== expectedSalt) {
return errorResponse('Invalid master password salt', 400);
}
if (
authKdf !== user.kdfType ||
authIterations !== user.kdfIterations ||
(authKdf === 1 && (authMemory !== user.kdfMemory || authParallelism !== user.kdfParallelism))
) {
return errorResponse('KDF settings cannot be changed with the password endpoint', 400);
}
} else {
if (!legacyMasterPasswordHash || !legacyKey) {
return errorResponse('newMasterPasswordHash and key must be provided together', 400);
}
newMasterPasswordHash = legacyMasterPasswordHash;
nextKey = legacyKey;
}
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) {
if (!looksLikeEncString(nextKey)) {
return errorResponse('new key is not a valid encrypted string', 400);
}
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
}
const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType;
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
if (
(typeof body.kdf === 'number' && body.kdf !== user.kdfType) ||
(typeof body.kdfIterations === 'number' && body.kdfIterations !== user.kdfIterations) ||
(typeof body.kdfMemory === 'number' && body.kdfMemory !== user.kdfMemory) ||
(typeof body.kdfParallelism === 'number' && body.kdfParallelism !== user.kdfParallelism)
) {
return errorResponse('KDF settings cannot be changed with the password endpoint', 400);
}
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof nextKdf === 'number') user.kdfType = nextKdf;
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
user.masterPasswordHint = body.masterPasswordHint;
if (shouldUpdateHint) {
user.masterPasswordHint = nextMasterPasswordHint ?? null;
}
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
@@ -753,6 +823,51 @@ function twoFactorAuthenticatorResponse(
};
}
function yubiKeyResponse(user: User): Record<string, unknown> {
return {
Enabled: isYubiKeyEnabled(user),
Key1: user.yubikeyKey1,
Key2: user.yubikeyKey2,
Key3: user.yubikeyKey3,
Key4: user.yubikeyKey4,
Key5: user.yubikeyKey5,
Nfc: !!user.yubikeyNfc,
Object: 'twoFactorYubiKey',
};
}
// New-device verification requires an email delivery channel to send OTP
// challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return {
Enabled: false,
enabled: false,
VerifyDevices: false,
verifyDevices: false,
Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings',
};
}
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
void storage;
const credentials = await getYubicoCredentials(env.DB);
const canManageCredentials = user.role === 'admin' && user.status === 'active';
return {
...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId,
YubicoCanManage: canManageCredentials,
...(canManageCredentials
? {
YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '',
}
: {}),
};
}
// GET /api/two-factor
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
void request;
@@ -760,9 +875,11 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
const data = user.totpSecret
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
: [];
const data = [];
if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
return jsonResponse({
Data: data,
@@ -794,6 +911,77 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
}
// POST /api/two-factor/get-yubikey
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/get-device-verification-settings
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
// Log the attempt for audit purposes — never change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request),
},
});
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -817,7 +1005,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
return errorResponse('User verification failed.', 400);
}
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
const matchedCounter = await findMatchingTotpCounter(key, token);
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid token.', 400);
}
user.totpSecret = key;
if (!user.totpRecoveryCode) {
@@ -840,6 +1031,182 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
}
// PUT/POST /api/two-factor/yubikey
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const keys = [
readBodyString(body, ['key1', 'Key1']),
readBodyString(body, ['key2', 'Key2']),
readBodyString(body, ['key3', 'Key3']),
readBodyString(body, ['key4', 'Key4']),
readBodyString(body, ['key5', 'Key5']),
];
const publicIds: Array<string | null> = [];
let credentials = await getYubicoCredentials(env.DB);
let apiKeyBootstrapOtpIndex: number | null = null;
for (const key of keys) {
const trimmed = key.trim();
if (!trimmed) {
publicIds.push(null);
continue;
}
const publicId = yubiKeyPublicIdFromOtp(trimmed);
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
if (isYubiKeyPublicId(trimmed)) {
publicIds.push(publicId);
continue;
}
if (!credentials) {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, trimmed);
if (!initialized) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
credentials = initialized.credentials;
if (initialized.created) apiKeyBootstrapOtpIndex = publicIds.length;
}
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
return errorResponse('Invalid YubiKey OTP.', 400);
}
publicIds.push(publicId);
}
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
user.yubikeyKey1 = publicIds[0] ?? null;
user.yubikeyKey2 = publicIds[1] ?? null;
user.yubikeyKey3 = publicIds[2] ?? null;
user.yubikeyKey4 = publicIds[3] ?? null;
user.yubikeyKey5 = publicIds[4] ?? null;
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.yubikey.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// PUT/POST /api/two-factor/yubikey/config
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
if (user.role !== 'admin' || user.status !== 'active') return errorResponse('Forbidden', 403);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
if (!clientId || !secretKey) return errorResponse('Yubico Client ID and Secret Key are required.', 400);
await replaceYubicoCredentials(env.DB, { clientId, secretKey });
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'system.yubico.credentials.update',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/yubikey/bootstrap
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
const existing = await getYubicoCredentials(env.DB);
if (user.role !== 'admin' && existing) {
return errorResponse('Yubico validation credentials are already configured.', 403);
}
let credentials;
if (user.role === 'admin') {
credentials = await requestYubicoApiCredentials(user.email, otp);
if (!credentials?.clientId || !credentials.secretKey) {
return errorResponse('Unable to initialize Yubico validation credentials.', 400);
}
await replaceYubicoCredentials(env.DB, credentials);
} else {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, otp);
if (!initialized?.created) {
return errorResponse(
initialized?.credentials
? 'Yubico validation credentials are already configured.'
: 'Unable to initialize Yubico validation credentials.',
initialized?.credentials ? 403 : 400
);
}
credentials = initialized.credentials;
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: user.role === 'admin'
? 'system.yubico.credentials.reconfigure'
: 'system.yubico.credentials.initialize',
category: 'security',
level: 'security',
targetType: 'system',
targetId: 'yubico',
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -856,30 +1223,40 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false;
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.totpSecret = null;
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
user.totpSecret = null;
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
} else {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of credentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
? 'account.totp.disable'
: type === TWO_FACTOR_PROVIDER_YUBIKEY
? 'account.yubikey.disable'
: 'account.webauthn_2fa.disable',
category: 'security',
level: 'security',
targetType: 'user',
@@ -887,11 +1264,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
return jsonResponse(twoFactorProviderResponse(type, false));
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -899,7 +1276,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try {
body = await request.json();
} catch {
@@ -908,14 +1291,26 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
return errorResponse('TOTP token is required', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) {
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid TOTP token', 400);
}
user.totpSecret = normalizedSecret;
@@ -1063,6 +1458,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
}
user.totpSecret = null;
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of webAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
@@ -1165,26 +1570,30 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings
if (!rotate && isStoredApiKeyHash(user.apiKey)) {
return errorResponse(
'This API key was created by an older NodeWarden version and cannot be displayed. Rotate it once to use the Bitwarden-compatible readable format.',
409
);
}
let auditAction = 'account.api_key.view';
if (rotate || !user.apiKey) {
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
auditAction = rotate ? 'account.api_key.rotate' : 'account.api_key.create';
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: auditAction,
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({
apiKey: user.apiKey,
+75 -24
View File
@@ -9,6 +9,34 @@ 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('');
@@ -69,18 +97,22 @@ 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,
});
@@ -183,6 +215,9 @@ export async function handleAdminClearAuditLogs(
}
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 });
}
@@ -197,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();
@@ -249,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,
@@ -259,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', null, null, request);
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 });
}
@@ -275,12 +313,25 @@ 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,
@@ -300,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) {
@@ -348,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
@@ -356,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) {
+30 -28
View File
@@ -1,9 +1,10 @@
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
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,
@@ -123,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,
@@ -166,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);
}
@@ -204,7 +209,7 @@ 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);
@@ -214,7 +219,7 @@ export async function handleCreateAttachment(
}
// 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) {
@@ -243,13 +248,13 @@ 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);
}
@@ -282,12 +287,12 @@ 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);
}
@@ -307,13 +312,13 @@ 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);
}
@@ -348,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);
}
@@ -404,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');
@@ -417,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);
}
@@ -436,20 +439,19 @@ 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',
@@ -470,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);
}
@@ -485,7 +487,7 @@ 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);
@@ -500,7 +502,7 @@ export async function handleDeleteAttachment(
}
// 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);
+116 -7
View File
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
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;
@@ -14,6 +16,19 @@ 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') ||
@@ -81,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: authRequest.masterPasswordHash,
MasterPasswordHash: authRequest.masterPasswordHash,
masterPasswordHash: null,
MasterPasswordHash: null,
creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate,
@@ -118,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
}
}
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];
@@ -151,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
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);
}
@@ -186,9 +227,75 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
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.getAuthRequestById(id);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
@@ -226,7 +333,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestById(id);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
@@ -242,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) ||
@@ -251,15 +357,18 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
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,
masterPasswordHash: null,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id);
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) {
+227 -55
View File
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import {
type BackupArchiveBundle,
MAX_BACKUP_ARCHIVE_BYTES,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
@@ -18,9 +20,11 @@ import {
loadBackupSettings,
normalizeBackupSettingsInput,
normalizeImportedBackupSettings,
redactBackupSettingsSecrets,
repairBackupSettings,
requireBackupDestination,
saveBackupSettings,
updateBackupDestinationRuntime,
} from '../services/backup-config';
import {
type BackupImportExecutionResult,
@@ -40,15 +44,60 @@ import {
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
function parseRequestContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog(
storage: StorageService,
actorUserId: string | null,
@@ -92,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
if (!normalized) {
throw new Error('Backup attachment blob is required');
}
const parts = normalized.split('/').filter(Boolean);
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
if (!isSafeBackupAttachmentBlobName(normalized)) {
throw new Error('Backup attachment blob is invalid');
}
return parts.join('/');
return normalized;
}
function contentDispositionBackup(fileName: string | null | undefined): string {
const fallback = 'nodewarden_backup.zip';
const value = String(fileName || fallback)
.replace(/[\\/\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
@@ -224,6 +280,30 @@ async function uploadRemoteAttachmentChunk(
}
}
async function verifyUploadedBackupArchive(
session: RemoteBackupTransferSession,
archive: BackupArchiveBundle
): Promise<'metadata' | 'download'> {
try {
const stat = await session.stat(archive.fileName);
if (stat?.size === archive.bytes.byteLength) {
return 'metadata';
}
} catch {
// Fall through to a full read-back verification when lightweight metadata is unavailable.
}
const remoteFile = await session.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
return 'download';
}
export async function executeConfiguredBackup(
env: Env,
storage: StorageService,
@@ -251,12 +331,14 @@ export async function executeConfiguredBackup(
const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString();
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastAttemptAt: now.toISOString(),
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
lastErrorAt: null,
lastErrorMessage: null,
}));
try {
await touchLease();
@@ -318,6 +400,7 @@ export async function executeConfiguredBackup(
}
}
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease();
await progress?.({
@@ -337,14 +420,7 @@ export async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
});
const remoteFile = await remoteSession.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
break;
} catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
@@ -373,14 +449,16 @@ export async function executeConfiguredBackup(
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
}
destination.runtime.lastSuccessAt = new Date().toISOString();
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: archive.fileName,
lastUploadedSizeBytes: archive.bytes.byteLength,
lastUploadedDestination: upload.remotePath,
}));
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
@@ -390,6 +468,7 @@ export async function executeConfiguredBackup(
fileName: archive.fileName,
fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts,
uploadVerificationMethod,
prunedFileCount,
pruneError: pruneErrorMessage,
...(auditMetadata || {}),
@@ -412,15 +491,18 @@ export async function executeConfiguredBackup(
provider: upload.provider,
};
} catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString();
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastErrorAt: new Date().toISOString(),
lastErrorMessage: errorMessage,
}));
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
error: errorMessage,
...(auditMetadata || {}),
});
await progress?.({
@@ -431,7 +513,7 @@ export async function executeConfiguredBackup(
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true,
ok: false,
error: destination.runtime.lastErrorMessage,
error: errorMessage,
});
throw error;
}
@@ -591,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
if (blobName && !seen.has(blobName)) {
seen.add(blobName);
names.push(blobName);
@@ -603,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
function toImportStatusCode(message: string): number {
const lower = message.toLowerCase();
if (lower.includes('checksum')) return 400;
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409;
@@ -619,12 +703,18 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting: boolean,
checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null
targetDeviceIdentifier: string | null = null,
keepAlive?: (() => Promise<void>) | null
): Promise<BackupImportExecutionResult> {
const touchLease = async () => {
await keepAlive?.();
};
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
await touchLease();
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => {
await touchLease();
await notifyUserBackupRestoreProgress(
env,
actorUserId,
@@ -642,6 +732,7 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting,
{
loadAttachment: async (blobName) => {
await touchLease();
const normalized = String(blobName || '').trim();
if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) {
@@ -664,6 +755,7 @@ export async function importAndAuditRemoteBackupFile(
} catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
}
await touchLease();
return externalAttachmentCache.get(normalized) || null;
},
},
@@ -778,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse(settings);
return jsonResponse(redactBackupSettingsSecrets(settings));
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
}
@@ -787,13 +879,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch {
return errorResponse('Backup settings payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
let previous;
try {
@@ -814,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request);
return jsonResponse(next);
return jsonResponse(redactBackupSettingsSecrets(next));
}
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -837,13 +932,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch {
return errorResponse('Backup settings repair payload is invalid', 400);
}
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
let previous;
try {
@@ -864,22 +962,25 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request);
return jsonResponse(next);
return jsonResponse(redactBackupSettingsSecrets(next));
}
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try {
let body: { destinationId?: string } | null = null;
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>();
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
}
} catch {
return errorResponse('Backup run payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request),
@@ -898,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
provider: outcome.result.provider,
remotePath: outcome.result.remotePath,
},
settings: outcome.settings,
settings: redactBackupSettingsSecrets(outcome.settings),
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
@@ -928,19 +1029,29 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, {
status: 200,
headers: {
'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
@@ -951,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup integrity payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({
@@ -974,12 +1094,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup delete payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
@@ -994,13 +1123,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
return errorResponse('Remote restore payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try {
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1166,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null;
let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>();
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
}
} catch {
return errorResponse('Backup export payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle;
try {
const progress = async (event: {
@@ -1096,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
'Content-Disposition': contentDispositionBackup(archive.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -1106,8 +1247,24 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try {
const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
// Read the request body only. Accepting these fields from the query string
// would put the master-password authentication hash in the URL, where it is
// captured by request logs, browser history and Referer headers.
let input: { blobName?: unknown; masterPasswordHash?: unknown };
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName);
if (!object) {
return errorResponse('Backup attachment blob not found', 404);
@@ -1128,6 +1285,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const contentType = request.headers.get('Content-Type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
}
const declaredSize = parseRequestContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
let formData: FormData;
try {
formData = await request.formData();
@@ -1139,6 +1305,12 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400);
}
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
+139 -15
View File
@@ -7,6 +7,9 @@ import {
CipherResponse,
CipherSecureNote,
CipherSshKey,
CipherBankAccount,
CipherDriversLicense,
CipherPassport,
Attachment,
PasswordHistory,
} from '../types';
@@ -32,6 +35,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients.
export interface CipherResponseOptions {
preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
}
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -48,6 +52,12 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null;
}
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
@@ -247,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
return next as T;
}
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
'bankName',
'nameOnAccount',
'accountType',
'accountNumber',
'routingNumber',
'branchNumber',
'pin',
'swiftCode',
'iban',
'bankContactPhone',
] as const;
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
'firstName',
'middleName',
'lastName',
'dateOfBirth',
'licenseNumber',
'issuingCountry',
'issuingState',
'issueDate',
'expirationDate',
'issuingAuthority',
'licenseClass',
] as const;
const PASSPORT_ENCRYPTED_KEYS = [
'surname',
'givenName',
'dateOfBirth',
'sex',
'birthPlace',
'nationality',
'issuingCountry',
'passportNumber',
'passportType',
'nationalIdentificationNumber',
'issuingAuthority',
'issueDate',
'expirationDate',
] as const;
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -347,6 +400,48 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
}
}
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
if (Array.isArray(login.fido2Credentials)) {
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
for (const cred of login.fido2Credentials) {
if (!cred || typeof cred !== 'object') continue;
for (const key of fido2EncryptedKeys) {
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
}
}
}
}
// Validate SSH key fields — all three must be encrypted strings.
const sshKey = cipher.sshKey as any;
if (sshKey && typeof sshKey === 'object') {
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
];
for (const [label, source, keys] of typedEncryptedObjects) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
return `${label} ${key} must be an encrypted string.`;
}
}
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
if (!entry || typeof entry !== 'object') continue;
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
}
}
return null;
@@ -717,7 +812,20 @@ export function cipherToResponse(
'licenseNumber',
]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2
const normalizedBankAccount = sanitizeEncryptedObject(
(passthrough as any).bankAccount ?? null,
BANK_ACCOUNT_ENCRYPTED_KEYS
);
const normalizedDriversLicense = sanitizeEncryptedObject(
(passthrough as any).driversLicense ?? null,
DRIVERS_LICENSE_ENCRYPTED_KEYS
);
const normalizedPassport = sanitizeEncryptedObject(
(passthrough as any).passport ?? null,
PASSPORT_ENCRYPTED_KEYS
);
const responseType = Number(cipher.type) || 1;
const normalizedSecureNote = responseType === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
@@ -727,8 +835,8 @@ export function cipherToResponse(
// Pass through ALL stored cipher fields (known + unknown)
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1,
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: responseType,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt,
@@ -750,6 +858,9 @@ export function cipherToResponse(
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey,
bankAccount: responseType === 6 ? normalizedBankAccount : null,
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
passport: responseType === 8 ? normalizedPassport : null,
key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null,
@@ -785,9 +896,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id)
);
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request);
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
@@ -804,7 +916,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -819,8 +931,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true;
const folder = await storage.getFolder(folderId);
return !!(folder && folder.userId === userId);
const folder = await storage.getFolderForUser(folderId, userId);
return !!folder;
}
// POST /api/ciphers
@@ -844,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
@@ -873,6 +988,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -901,7 +1019,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id);
const existingCipher = await storage.getCipherForUser(id, userId);
if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -924,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
@@ -972,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
@@ -1012,7 +1136,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1044,7 +1168,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1071,7 +1195,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
// DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1096,7 +1220,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1118,7 +1242,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1210,7 +1334,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
// PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
@@ -1236,7 +1360,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
// PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
+98 -2
View File
@@ -6,7 +6,7 @@ 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);
@@ -48,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,
@@ -123,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
@@ -383,11 +464,26 @@ export async function handleUpdateDeviceName(
// 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),
+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);
}
+5 -5
View File
@@ -80,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);
@@ -129,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);
@@ -163,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);
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const folders = (
await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id);
return folder && folder.userId === userId ? folder : null;
const folder = await storage.getFolderForUser(id, userId);
return folder;
}))
).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
+261 -99
View File
@@ -1,10 +1,10 @@
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';
@@ -18,13 +18,21 @@ import {
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, yubiKeyPublicIdFromOtp } from '../utils/yubico-otp';
import { getYubicoCredentials, initializeYubicoCredentialsOnce } from '../services/yubico-config';
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';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
@@ -33,6 +41,10 @@ const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
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;
@@ -51,6 +63,33 @@ async function resolveDeviceSession(
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();
}
@@ -105,18 +144,6 @@ 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];
}
return diff === 0;
}
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) {
const value = body[name];
@@ -125,6 +152,16 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
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}`;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:';
const parts = [
@@ -147,7 +184,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, {
@@ -169,6 +206,16 @@ function buildPreloginResponse(
kdfIterations,
kdfMemory,
kdfParallelism,
// Current official servers expose the consolidated KDF model alongside
// the legacy flat fields. Keep both shapes while clients migrate.
kdfSettings: {
kdfType,
iterations: kdfIterations,
memory: kdfMemory,
parallelism: kdfParallelism,
},
salt: null,
// Preserve the historic NodeWarden aliases for older integrations.
KdfSettings: {
KdfType: kdfType,
Iterations: kdfIterations,
@@ -193,13 +240,32 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
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(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, { Email: null }> = {};
for (const provider of providers) providers2[provider] = { Email: null };
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,
@@ -208,7 +274,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
};
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse(
return identityJsonResponse(
{
error: 'invalid_grant',
error_description: message,
@@ -282,8 +348,20 @@ 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') {
@@ -294,13 +372,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
// 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);
@@ -336,10 +414,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
valid = !!(
authRequest &&
authRequest.userId === user.id &&
@@ -348,10 +427,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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);
@@ -377,10 +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 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());
@@ -390,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.');
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
}
let passedByRememberToken = false;
@@ -405,11 +488,51 @@ 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.');
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);
}
let credentials = await getYubicoCredentials(env.DB);
let initializedWithCurrentOtp = false;
if (!credentials) {
const initialized = await initializeYubicoCredentialsOnce(env.DB, user.email, normalizedTwoFactorToken);
if (!initialized) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
credentials = initialized.credentials;
initializedWithCurrentOtp = initialized.created;
}
if (!initializedWithCurrentOtp && !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 (
@@ -421,10 +544,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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.
@@ -444,15 +578,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
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);
}
@@ -463,7 +590,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
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, {
@@ -488,7 +615,7 @@ 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: user.key,
Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
@@ -506,13 +633,14 @@ 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;
} else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier;
const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier!, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
@@ -522,7 +650,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
@@ -567,22 +694,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
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);
}
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 userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,11 +742,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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;
@@ -637,11 +760,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = clientIdentifier;
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);
@@ -653,7 +777,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);
@@ -677,7 +800,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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,
@@ -696,15 +819,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
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);
}
@@ -712,7 +828,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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, {
@@ -754,7 +870,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;
@@ -771,7 +887,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',
@@ -796,13 +912,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',
@@ -810,19 +926,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)
@@ -832,7 +935,72 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessTokenDetailed(refreshToken);
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,
@@ -854,18 +1022,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: 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);
@@ -873,7 +1033,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,
@@ -892,9 +1052,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;
}
@@ -927,7 +1087,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
@@ -935,7 +1095,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 {
@@ -946,7 +1105,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() || (
@@ -958,7 +1117,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;
+27 -7
View File
@@ -17,6 +17,9 @@ 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; uriChecksum?: string | null; match?: number | null }> | 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']);
@@ -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,
+8
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';
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
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);
+37 -13
View File
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
import {
getBlobStorageMaxBytes,
getSendFileObjectKey,
getBlobObject,
putBlobObject,
deleteBlobObject,
} from '../services/blob-store';
@@ -34,6 +35,8 @@ import {
} 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,
@@ -82,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: {
@@ -134,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);
@@ -210,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 = {
@@ -334,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 = {
@@ -401,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);
}
@@ -436,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);
}
@@ -472,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);
}
@@ -485,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);
}
@@ -592,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']);
@@ -604,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;
}
}
@@ -632,7 +656,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
export async function handleDeleteSend(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);
}
@@ -698,7 +722,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
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);
}
@@ -719,7 +743,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
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);
}
+26 -16
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,
@@ -68,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) {
@@ -112,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);
@@ -143,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) {
@@ -291,22 +290,30 @@ 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);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
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);
if (!firstUse) {
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',
@@ -321,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) {
@@ -361,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);
+18 -6
View File
@@ -1,4 +1,4 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import {
notifyUserSendCreate,
notifyUserSendDelete,
@@ -155,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;
}
@@ -371,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 };
@@ -434,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 {
@@ -464,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 };
+2 -1
View File
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
.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 = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris });
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
+15 -17
View File
@@ -5,6 +5,11 @@ import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue } from './handlers/backup';
import {
isBackendRequestPath,
isWebVaultHidden,
webVaultNotFoundResponse,
} from './web-vault-visibility';
let dbInitialized = false;
let dbInitError: string | null = null;
@@ -19,19 +24,6 @@ function normalizeRequestUrl(request: Request): Request {
return new Request(url.toString(), request);
}
function isWorkerHandledPath(path: string): boolean {
return (
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
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();
@@ -55,7 +47,7 @@ async function maybeServeAsset(request: Request, env: Env): Promise<Response | n
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;
if (isBackendRequestPath(url.pathname)) return null;
const response = await env.ASSETS.fetch(request);
return addSearchIndexHeaders(request, response);
@@ -87,9 +79,15 @@ export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx;
const normalizedRequest = normalizeRequestUrl(request);
const requestPath = new URL(normalizedRequest.url).pathname;
if (isWebVaultHidden(env) && !isBackendRequestPath(requestPath)) {
return webVaultNotFoundResponse(normalizedRequest);
}
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
return applyCors(normalizedRequest, assetResponse, env);
}
await ensureDatabaseInitialized(env);
@@ -107,11 +105,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> {
+19 -4
View File
@@ -14,6 +14,7 @@ import {
handleRunAdminConfiguredBackup,
handleUpdateAdminBackupSettings,
} from './handlers/backup';
import { errorResponse } from './utils/response';
export async function handleAdminBackupRoute(
request: Request,
@@ -26,8 +27,22 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
if (path === '/api/admin/backup/blob') {
// POST only: this endpoint requires master-password verification, and a GET
// could only carry that credential in the query string, where it would leak
// into request logs, proxy logs, browser history and Referer headers.
// The credential is the same value clients send to /identity/connect/token,
// so a leaked copy is enough to sign in as this admin.
if (method === 'POST') {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
if (method === 'GET') {
return errorResponse(
'Use POST with a JSON body for this endpoint. Credentials must not be sent in the URL.',
405
);
}
return null;
}
if (path === '/api/admin/backup/settings') {
@@ -50,11 +65,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);
}
+26 -2
View File
@@ -4,7 +4,7 @@ import {
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminDeleteInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
handleAdminListAuditLogs,
@@ -13,6 +13,23 @@ import {
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,
@@ -21,6 +38,13 @@ 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);
}
@@ -52,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);
+95 -4
View File
@@ -1,5 +1,5 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
@@ -15,6 +15,12 @@ import {
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleGetTwoFactorYubiKey,
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleGetDeviceVerificationSettings,
handlePutDeviceVerificationSettings,
handleDisableTwoFactorProvider,
handleGetApiKey,
handleRotateApiKey,
@@ -74,12 +80,17 @@ 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,
@@ -106,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);
@@ -141,12 +186,53 @@ export async function handleAuthenticatedRoute(
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);
}
@@ -294,17 +380,22 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
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') {
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
if (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]);
+7
View File
@@ -18,6 +18,8 @@ import {
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
handleRegisterDevice,
handleReportLostTrust,
} from './handlers/devices';
function devicesPath(pattern: string): RegExp {
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
): Promise<Response | null> {
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/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);
+76 -62
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import {
handleAccessSend,
handleAccessSendFile,
@@ -8,6 +7,11 @@ 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 {
@@ -27,18 +31,27 @@ import {
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';
import { buildConfigResponse } from './config-response';
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 {
@@ -83,56 +96,6 @@ function handleMissingWebsiteIcon(): Response {
});
}
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function buildConfigResponse(origin: string) {
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null {
let decoded: string;
try {
@@ -241,11 +204,16 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
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(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
if (!isWebsiteIconProxyEnabled(env)) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -272,7 +240,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
@@ -301,9 +269,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason =
!secret
? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
const storage = new StorageService(env.DB);
@@ -314,6 +280,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
};
}
@@ -340,12 +308,31 @@ export async function handlePublicRoute(
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);
@@ -395,13 +382,13 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
}
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
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);
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;
@@ -448,9 +435,34 @@ export async function handlePublicRoute(
}
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;
@@ -467,7 +479,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') {
@@ -497,6 +509,8 @@ export async function handlePublicRoute(
}
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;
+92 -19
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);
+50 -17
View File
@@ -1,5 +1,6 @@
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.
@@ -28,11 +29,12 @@ 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 }
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null; expiresAt: number }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
@@ -190,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;
}
@@ -251,25 +267,42 @@ export class AuthService {
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;
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
if (record.deviceIdentifier) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (record.deviceSessionStamp && boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
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 boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
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 };
}
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device };
return { ok: true, accessToken, user, device, expiresAt };
}
async refreshAccessToken(
+96 -34
View File
@@ -1,7 +1,8 @@
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 { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import {
getAttachmentObjectKey,
@@ -16,6 +17,8 @@ import {
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json.
// - Runtime authentication state (devices, sessions, auth requests, remembered
// 2FA devices, and one-time tokens) must never enter an instance backup.
// - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance.
@@ -28,10 +31,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// 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;
@@ -68,7 +72,6 @@ export interface BackupPayload {
ciphers: SqlRow[];
attachments: SqlRow[];
webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
};
}
@@ -110,7 +113,7 @@ 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 || key === BACKUP_RUNNER_LOCK_CONFIG_KEY || key === YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
@@ -186,6 +189,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
}
}
function isSafeBackupPathSegment(value: string): boolean {
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
if (value === '.' || value === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(value);
}
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
const normalized = String(value ?? '').trim();
const parts = normalized.split('/');
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
}
function isSafeBackupAttachmentEntryName(value: string): boolean {
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
const relative = value.slice('attachments/'.length, -'.bin'.length);
return isSafeBackupAttachmentBlobName(relative);
}
function validateBackupEntryName(name: string): void {
const normalized = String(name || '').trim();
if (normalized !== name || !normalized) {
throw new Error('Backup archive contains an invalid file name');
}
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
}
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
}
}
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalOriginalBytes = 0;
return (file: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
validateBackupEntryName(file.name);
const originalSize = Number(file.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
}
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
totalOriginalBytes += originalSize;
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
return true;
};
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = [];
for (const row of db.attachments) {
@@ -204,6 +262,25 @@ function ensureRowArray(value: unknown, table: string): SqlRow[] {
return value as SqlRow[];
}
function normalizeParsedBackupDb(value: unknown): BackupPayload['db'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Backup archive database payload is invalid');
}
const source = value as Record<string, unknown>;
// Restore uses an explicit allowlist. Extra tables from old or modified
// archives, especially runtime authentication state, are intentionally ignored.
return {
config: source.config as SqlRow[],
users: source.users as SqlRow[],
domain_settings: source.domain_settings as SqlRow[],
user_revisions: source.user_revisions as SqlRow[],
folders: source.folders as SqlRow[],
ciphers: source.ciphers as SqlRow[],
attachments: source.attachments as SqlRow[],
webauthn_credentials: source.webauthn_credentials as SqlRow[] | undefined,
};
}
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
for (const [path, bytes] of Object.entries(files)) {
@@ -223,8 +300,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');
}
@@ -235,6 +315,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) {
@@ -253,10 +334,10 @@ export function parseBackupArchive(
const decoder = new TextDecoder();
let manifest: BackupManifest;
let db: BackupPayload['db'];
let rawDb: unknown;
try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
rawDb = JSON.parse(decoder.decode(dbBytes));
} catch {
throw new Error('Backup archive contains invalid JSON metadata');
}
@@ -264,9 +345,7 @@ export function parseBackupArchive(
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
throw new Error('Unsupported backup format version');
}
if (!db || typeof db !== 'object') {
throw new Error('Backup archive database payload is invalid');
}
const db = normalizeParsedBackupDb(rawDb);
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
@@ -303,7 +382,6 @@ export function validateBackupPayloadContents(
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`)
@@ -368,7 +446,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`;
@@ -382,9 +460,10 @@ export function validateBackupPayloadContents(
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) {
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}`);
@@ -393,20 +472,6 @@ export function validateBackupPayloadContents(
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(
@@ -425,16 +490,15 @@ export async function buildBackupArchive(
includeAttachments,
});
const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -463,7 +527,6 @@ export async function buildBackupArchive(
ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
},
includes: {
attachments: includeAttachments,
@@ -487,7 +550,6 @@ export async function buildBackupArchive(
ciphers: cipherRows,
attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)),
};
+296 -17
View File
@@ -26,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 {
@@ -66,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());
@@ -121,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');
@@ -134,7 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket,
addressingStyle,
region,
@@ -153,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');
@@ -163,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,
@@ -179,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 => {
@@ -249,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,
@@ -324,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));
}
@@ -387,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');
}
@@ -417,6 +675,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
const users = await storage.getAllUsers();
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> {
@@ -596,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);
@@ -620,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;
}
}
@@ -637,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(
@@ -652,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;
+33 -29
View File
@@ -1,9 +1,11 @@
import type { Env, User } from '../types';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
import { YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY } from './yubico-config';
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
@@ -18,13 +20,14 @@ import {
// shadow-table count validation, insert column lists, and frontend import
// count types together.
// - Do not import users.api_key, even if an older backup contains it.
// - Do not import, clear, or replace runtime authentication state such as
// devices, sessions, auth requests, or remembered 2FA device tokens.
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'domain_settings'
| 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials'
| 'folders'
| 'ciphers'
@@ -35,7 +38,6 @@ const BACKUP_TABLES: BackupTableName[] = [
'users',
'domain_settings',
'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials',
'folders',
'ciphers',
@@ -53,7 +55,6 @@ export interface BackupImportResultBody {
users: number;
domainSettings: number;
userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number;
folders: number;
ciphers: number;
@@ -175,7 +176,6 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'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',
@@ -253,6 +253,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) => {
@@ -271,7 +275,9 @@ async function prepareImportedConfigRows(
configRows: SqlRow[],
userRows: SqlRow[]
): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []);
let nextConfigRows = cloneRows(configRows || []).filter(
(row) => String(row.key || '').trim() !== YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY
);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
@@ -296,12 +302,15 @@ 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 || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose),
})),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
@@ -461,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(
@@ -619,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', '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 || []
)
);
@@ -639,23 +659,13 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true
)
);
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch(
db,
tableName('webauthn_credentials'),
buildInsertStatements(
db,
tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
['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 || []
)
);
@@ -727,7 +737,6 @@ export async function importBackupArchiveBytes(
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,
@@ -751,7 +760,6 @@ export async function importBackupArchiveBytes(
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,
@@ -793,7 +801,6 @@ export async function importBackupArchiveBytes(
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,
@@ -871,7 +878,6 @@ export async function importRemoteBackupArchiveBytes(
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,
@@ -895,7 +901,6 @@ export async function importRemoteBackupArchiveBytes(
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,
@@ -943,7 +948,6 @@ export async function importRemoteBackupArchiveBytes(
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,
+99 -59
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,11 +453,17 @@ 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 {
@@ -501,7 +519,7 @@ async function signedS3Request(
config.region || 'auto'
);
return fetch(url.toString(), {
return fetch(url, {
method,
headers: {
Authorization: authorization,
@@ -540,61 +558,68 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 listing failed: ${response.status}`);
}
const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
if (!fullPrefix) continue;
const relative = rootPrefix
? fullPrefix === rootPrefix
? ''
: fullPrefix.startsWith(`${rootPrefix}/`)
? fullPrefix.slice(rootPrefix.length + 1)
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) {
throw new Error(`S3 listing failed: ${response.status}`);
}
const xml = await response.text();
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
if (!fullPrefix) continue;
const relative = rootPrefix
? fullPrefix === rootPrefix
? ''
: fullPrefix.startsWith(`${rootPrefix}/`)
? fullPrefix.slice(rootPrefix.length + 1)
: ''
: fullPrefix;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative) continue;
const itemPath = normalizedRelative.replace(/\/+$/, '');
if ((parentPath(itemPath) || '') !== currentPath) continue;
items.push({
path: itemPath,
name: basename(itemPath) || itemPath,
isDirectory: true,
size: null,
modifiedAt: null,
});
}
for (const content of extractXmlBlocks(xml, 'Contents')) {
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
const relative = rootPrefix
? fullKey.startsWith(`${rootPrefix}/`)
? fullKey.slice(rootPrefix.length + 1)
: ''
: fullPrefix;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative) continue;
const itemPath = normalizedRelative.replace(/\/+$/, '');
if ((parentPath(itemPath) || '') !== currentPath) continue;
items.push({
path: itemPath,
name: basename(itemPath) || itemPath,
isDirectory: true,
size: null,
modifiedAt: null,
});
}
: fullKey;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
items.push({
path: normalizedRelative,
name: basename(normalizedRelative) || normalizedRelative,
isDirectory: false,
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
});
}
for (const content of extractXmlBlocks(xml, 'Contents')) {
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
const relative = rootPrefix
? fullKey.startsWith(`${rootPrefix}/`)
? fullKey.slice(rootPrefix.length + 1)
: ''
: fullKey;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
items.push({
path: normalizedRelative,
name: basename(normalizedRelative) || normalizedRelative,
isDirectory: false,
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
});
}
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);
@@ -637,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 = 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 {
@@ -656,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 {
@@ -666,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(
@@ -683,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') {
@@ -695,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),
};
}
@@ -730,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)),
};
}
+4 -21
View File
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
method: 'POST',
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
},
body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(),
region: 'us',
}),
},
'Failed to request Bitwarden push installation:'
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
const id = String(body?.id || '').trim();
const key = String(body?.key || '').trim();
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;
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
return postToPushRelay(env, '/push/delete', { id: normalized });
}
export async function notifyMobilePush(
+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
+21 -14
View File
@@ -7,6 +7,7 @@ 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 ''" },
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
"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)'
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
function mapCredentialRow(row: {
id: string;
user_id: string;
purpose?: string | null;
name: string;
public_key: string;
credential_id: string;
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
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,
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
await safeBind(
db.prepare(
'INSERT INTO webauthn_credentials(' +
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'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,
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
export async function listAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.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);
}
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
export async function countAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
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 = ?')
.bind(userId)
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
.bind(userId, purpose)
.first<{ count: number }>();
return Number(row?.count || 0);
}
@@ -262,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
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 = ?'
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
)
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run();
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
export async function deleteAccountPasskeyCredential(
db: D1Database,
userId: string,
id: 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 = ?')
.bind(userId, id)
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
.bind(userId, id, purpose)
.run();
return Number(result.meta.changes || 0) > 0;
}
+36 -4
View File
@@ -117,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
}
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
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);
+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();
}
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
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);
+10 -1
View File
@@ -107,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);
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'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,
+18 -2
View File
@@ -45,7 +45,8 @@ export async function upsertDevice(
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, ' +
'session_stamp=CASE WHEN devices.session_stamp IS NULL OR devices.session_stamp = ? THEN excluded.session_stamp ELSE devices.session_stamp END, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
@@ -66,7 +67,8 @@ export async function upsertDevice(
existingDevice?.deviceNote ?? null,
now,
now,
now
now,
''
)
.run();
}
@@ -97,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,
+37 -15
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();
}
@@ -54,34 +68,42 @@ export async function bulkDeleteFolders(
db: D1Database,
userId: string,
ids: string[],
sqlChunkSize: (fixedBindCount: number) => number,
sqlChunkSize: (fixedBindCount: number, bindCountPerItem?: number) => number,
updateRevisionDate: (userId: string) => Promise<string>
): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2);
// Each folder ID is bound in all three compatibility predicates below.
const chunkSize = sqlChunkSize(2, 3);
const statements: D1PreparedStatement[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
statements.push(
db.prepare(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})`
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)
.run();
await db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
.bind(now, userId, ...chunk, ...chunk, ...chunk)
);
statements.push(
db.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
);
}
await db.batch(statements);
return updateRevisionDate(userId);
}
+91 -34
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();
}
@@ -36,12 +54,25 @@ export async function getRefreshTokenRecord(
const tokenKey = await refreshTokenKey(token);
const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.prepare(
'SELECT user_id, expires_at, device_identifier, device_session_stamp, security_stamp, created_at, last_used_at, absolute_expires_at, client_type ' +
'FROM refresh_tokens WHERE token = ?'
)
.bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
.first<{
user_id: string;
expires_at: number;
device_identifier: string | null;
device_session_stamp: string | null;
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;
}
@@ -50,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();
@@ -71,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();
}
+29 -4
View File
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'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 (' +
@@ -68,16 +74,26 @@ 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)',
@@ -111,6 +127,8 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE 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, ' +
@@ -126,11 +144,18 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'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)',
+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
+130 -22
View File
@@ -22,7 +22,10 @@ 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,
@@ -30,7 +33,7 @@ import {
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite,
revertInviteUsed as revertStoredInviteUsed,
} from './storage-admin-repo';
import {
bulkDeleteFolders as deleteStoredFolders,
@@ -38,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';
@@ -50,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,
@@ -57,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,
@@ -72,16 +80,19 @@ 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';
@@ -100,6 +111,7 @@ import {
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
@@ -111,6 +123,7 @@ import {
import {
createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById,
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
@@ -121,6 +134,9 @@ 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,
@@ -148,8 +164,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
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:
@@ -162,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) {}
@@ -190,10 +209,15 @@ export class StorageService {
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
}
private sqlChunkSize(fixedBindCount: number): number {
private sqlChunkSize(fixedBindCount: number, bindCountPerItem = 1): number {
const safeFixedBindCount = Math.max(0, Math.floor(fixedBindCount));
const safeBindCountPerItem = Math.max(1, Math.floor(bindCountPerItem));
return Math.max(
1,
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
Math.min(
LIMITS.performance.bulkMoveChunkSize,
Math.floor((StorageService.MAX_D1_SQL_VARIABLES - safeFixedBindCount) / safeBindCountPerItem)
)
);
}
@@ -313,8 +337,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> {
@@ -370,8 +406,11 @@ export class StorageService {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
}
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
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> {
@@ -382,8 +421,11 @@ export class StorageService {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
}
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
async countAccountPasskeyCredentialsByUserId(
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
}
async updateAccountPasskeyCounter(
@@ -414,8 +456,12 @@ export class StorageService {
);
}
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
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> {
@@ -437,6 +483,10 @@ export class StorageService {
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);
}
@@ -487,6 +537,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);
}
@@ -525,6 +579,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);
}
@@ -533,6 +591,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);
}
@@ -553,6 +615,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);
}
@@ -573,9 +639,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),
@@ -584,7 +654,10 @@ export class StorageService {
userId,
expiresAt,
deviceIdentifier,
deviceSessionStamp
deviceSessionStamp,
securityStamp,
clientType,
absoluteExpiresAtMs ?? (now + LIMITS.auth.refreshTokenAbsoluteTtlMs)
);
}
@@ -613,6 +686,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);
}
@@ -654,11 +731,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> {
@@ -699,6 +781,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,
@@ -762,6 +848,10 @@ export class StorageService {
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);
}
@@ -823,6 +913,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> {
+97
View File
@@ -0,0 +1,97 @@
import {
requestYubicoApiCredentials,
type YubicoApiCredentials,
} from '../utils/yubico-otp';
export const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
export const YUBICO_SECRET_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
export const YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY = 'yubico.bootstrap.claim.v1';
const YUBICO_BOOTSTRAP_CLAIM_TTL_MS = 2 * 60 * 1000;
export interface YubicoCredentialInitializationResult {
credentials: YubicoApiCredentials;
created: boolean;
}
export async function getYubicoCredentials(db: D1Database): Promise<YubicoApiCredentials | null> {
const result = await db
.prepare('SELECT key, value FROM config WHERE key IN (?, ?)')
.bind(YUBICO_CLIENT_ID_CONFIG_KEY, YUBICO_SECRET_KEY_CONFIG_KEY)
.all<{ key: string; value: string }>();
const values = new Map((result.results || []).map((row) => [row.key, String(row.value || '').trim()]));
const clientId = values.get(YUBICO_CLIENT_ID_CONFIG_KEY) || '';
const secretKey = values.get(YUBICO_SECRET_KEY_CONFIG_KEY) || '';
return clientId && secretKey ? { clientId, secretKey } : null;
}
export async function replaceYubicoCredentials(
db: D1Database,
credentials: YubicoApiCredentials
): Promise<void> {
const clientId = String(credentials.clientId || '').trim();
const secretKey = String(credentials.secretKey || '').trim();
if (!clientId || !secretKey) throw new Error('Yubico credentials are incomplete');
await db.batch([
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_CLIENT_ID_CONFIG_KEY, clientId),
db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).bind(YUBICO_SECRET_KEY_CONFIG_KEY, secretKey),
]);
}
async function acquireBootstrapClaim(db: D1Database): Promise<string | null> {
const now = Date.now();
await db
.prepare('DELETE FROM config WHERE key = ? AND CAST(value AS INTEGER) < ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, now)
.run();
const claim = `${now + YUBICO_BOOTSTRAP_CLAIM_TTL_MS}:${crypto.randomUUID()}`;
const result = await db
.prepare('INSERT OR IGNORE INTO config(key, value) VALUES(?, ?)')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
return (result.meta.changes ?? 0) > 0 ? claim : null;
}
async function releaseBootstrapClaim(db: D1Database, claim: string): Promise<void> {
await db
.prepare('DELETE FROM config WHERE key = ? AND value = ?')
.bind(YUBICO_BOOTSTRAP_CLAIM_CONFIG_KEY, claim)
.run();
}
export async function initializeYubicoCredentialsOnce(
db: D1Database,
email: string,
otp: string
): Promise<YubicoCredentialInitializationResult | null> {
const existing = await getYubicoCredentials(db);
if (existing) return { credentials: existing, created: false };
const claim = await acquireBootstrapClaim(db);
if (!claim) {
const concurrentlyCreated = await getYubicoCredentials(db);
return concurrentlyCreated ? { credentials: concurrentlyCreated, created: false } : null;
}
try {
const rechecked = await getYubicoCredentials(db);
if (rechecked) return { credentials: rechecked, created: false };
const issued = await requestYubicoApiCredentials(email, otp);
if (!issued?.clientId || !issued.secretKey) return null;
const configuredDuringRequest = await getYubicoCredentials(db);
if (configuredDuringRequest) {
return { credentials: configuredDuringRequest, created: false };
}
await replaceYubicoCredentials(db, issued);
return { credentials: issued, created: true };
} finally {
await releaseBootstrapClaim(db, claim).catch(() => undefined);
}
}
+82 -5
View File
@@ -6,6 +6,8 @@ export interface Env {
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
// Set to "1" to return 404 for the Web Vault while keeping client APIs available.
HIDE_WEB_VAULT?: string;
// Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required).
@@ -14,15 +16,13 @@ export interface Env {
WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string;
YUBICO_VALIDATION_URLS?: 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;
@@ -53,6 +53,12 @@ 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;
@@ -116,6 +122,10 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SSHKey = 5,
BankAccount = 6,
DriversLicense = 7,
Passport = 8,
}
export interface CipherLoginUri {
@@ -150,6 +160,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;
@@ -200,6 +256,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;
@@ -244,6 +303,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential {
id: string;
userId: string;
purpose: 'login' | 'twoFactor';
name: string;
publicKey: string;
credentialId: string;
@@ -259,7 +319,12 @@ export interface AccountPasskeyCredential {
updatedAt: string;
}
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
export type AccountPasskeyChallengeScope =
| 'Authentication'
| 'CreateCredential'
| 'UpdateKeySet'
| 'TwoFactorAuthentication'
| 'TwoFactorCreate';
export interface AccountPasskeyChallenge {
challengeHash: string;
@@ -307,6 +372,7 @@ export interface DeviceResponse {
type: number;
creationDate: string;
revisionDate: string;
lastActivityDate?: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
@@ -334,6 +400,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 {
@@ -466,6 +537,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
@@ -499,6 +572,7 @@ export interface ProfileResponse {
masterPasswordHint: string | null;
culture: string;
twoFactorEnabled: boolean;
yubikeyEnabled?: boolean;
key: string;
privateKey: string | null;
accountKeys: any | null;
@@ -529,6 +603,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;
+60 -30
View File
@@ -12,6 +12,7 @@ import type {
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;
@@ -32,6 +33,44 @@ 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']);
}
@@ -59,7 +98,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
}
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
}
export async function createAccountPasskeyToken(
@@ -119,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) };
}
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return textBytes(userId);
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
}
export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null;
try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
const bytes = base64UrlToBytes(userHandle);
const officialGuid = dotNetGuidBytesToUuid(bytes);
if (officialGuid) return officialGuid;
const decoded = new TextDecoder().decode(bytes);
return decoded.trim() || null;
} catch {
return null;
@@ -207,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
attestationObject: String(response.attestationObject),
clientDataJSON: String(clientDataJSON),
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
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 ? String(response.publicKey) : undefined,
publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
},
};
@@ -230,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
authenticatorData: String(response.authenticatorData),
clientDataJSON: String(clientDataJSON),
signature: String(response.signature),
userHandle: response.userHandle ? String(response.userHandle) : undefined,
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
signature: normalizeWebAuthnBase64(response.signature),
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
},
};
}
+42
View File
@@ -0,0 +1,42 @@
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 (!stored) return false;
// Legacy NodeWarden rows stored a one-way hash. Keep them usable until the
// user explicitly rotates once into the Bitwarden-compatible readable form.
if (!isStoredApiKeyHash(stored)) {
return constantTimeEquals(apiKey, stored);
}
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) {
+67
View File
@@ -0,0 +1,67 @@
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;
// Bitwarden desktop is migrating from file:// to this privileged Electron
// origin. Official clients keep the legacy file:// path as a compatibility
// fallback while self-hosted servers add CORS support for the new origin.
export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [
'bw-desktop-file://bundle',
] 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 isOfficialBitwardenDesktopOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes(
normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number]
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>([
...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS,
...OFFICIAL_BITWARDEN_DESKTOP_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);
}
+6 -2
View File
@@ -1,5 +1,6 @@
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;
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
yubikeyEnabled: isYubiKeyEnabled(user),
key: user.key,
privateKey: user.privateKey,
accountKeys,
@@ -28,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
// 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',
+53 -22
View File
@@ -1,4 +1,11 @@
import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
isOfficialBitwardenDesktopOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [
@@ -18,35 +25,34 @@ 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 (!origin) {
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) || isOfficialBitwardenDesktopOrigin(origin))
&& isConfiguredWebAuthnAllowedOrigin(env, origin)
) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
@@ -55,7 +61,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
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())
@@ -69,7 +75,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) {
@@ -83,7 +89,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;
@@ -92,15 +99,29 @@ 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);
}
// Security headers applied to every response.
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html';
if (isWebAuthnFrameConnector) {
// Official desktop and browser clients render this exact endpoint inside a
// 40px cross-origin iframe. The connector validates its parent before any
// WebAuthn request or postMessage, so only this protocol page may be framed.
headers.delete('X-Frame-Options');
headers.set(
'Content-Security-Policy',
"default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
);
} else {
headers.set('X-Frame-Options', 'DENY');
}
if (!isWebAuthnFrameConnector && !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,
@@ -134,8 +155,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,
@@ -145,15 +175,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 {
+4
View File
@@ -17,8 +17,12 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
wrappedPrivateKey: user.privateKey,
publicKey,
signedPublicKey: null,
object: 'publicKeyEncryptionKeyPair',
Object: 'publicKeyEncryptionKeyPair',
},
securityState: null,
signatureKeyPair: null,
object: 'privateKeys',
Object: 'privateKeys',
};
}
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+180
View File
@@ -0,0 +1,180 @@
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;
}
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
): Promise<boolean> {
const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return false;
const clientId = String(credentials?.clientId || '').trim();
const secretKey = String(credentials?.secretKey || '').trim();
if (!clientId || !secretKey) return false;
const nonce = randomNonce();
const params = new URLSearchParams({
id: clientId,
nonce,
otp,
});
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 (!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;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Env } from './types';
const BACKEND_PATH_PREFIXES = [
'/api',
'/identity',
'/icons',
'/fill-assist',
'/notifications',
'/.well-known',
// Compatibility aliases retained for older Bitwarden clients.
'/devices',
'/auth-requests',
'/webauthn',
] as const;
const BACKEND_EXACT_PATHS = new Set([
'/v1/assetlinks:check',
'/web-bootstrap',
'/config',
'/accounts/kdf',
'/settings/domains',
]);
export function isBackendRequestPath(pathname: string): boolean {
const path = pathname.toLowerCase();
if (BACKEND_EXACT_PATHS.has(path)) return true;
return BACKEND_PATH_PREFIXES.some((prefix) => (
path === prefix || path.startsWith(`${prefix}/`)
));
}
export function isWebVaultHidden(env: Env): boolean {
return String(env.HIDE_WEB_VAULT || '').trim() === '1';
}
export function webVaultNotFoundResponse(request: Request): Response {
const body = request.method === 'HEAD' ? null : 'Not Found';
return new Response(body, {
status: 404,
headers: {
'Cache-Control': 'no-store, max-age=0',
'Content-Type': 'text/plain; charset=utf-8',
'X-Robots-Tag': 'noindex, nofollow, noarchive, nosnippet',
},
});
}
+1 -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';
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
button {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 1px solid #2563eb;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
line-height: 1.2;
transition: background-color 160ms ease, border-color 160ms ease;
}
button:hover {
border-color: #1d4ed8;
background: #1d4ed8;
}
button:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
button[aria-disabled="true"] {
border-color: #d0d5dd;
background: #d0d5dd;
color: #667085;
cursor: not-allowed;
}
</style>
</head>
<body>
<button id="webauthn-button" type="button" aria-live="polite">Read security key</button>
<script type="module" src="/webauthn-connector.js"></script>
</body>
</html>
+268
View File
@@ -0,0 +1,268 @@
const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle';
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('Cannot parse WebAuthn data.');
}
}
export function base64UrlFromBuffer(value) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function parseConnectorRequest(search) {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const parentUrl = safeDecodeURIComponent(params.get('parent'));
const encodedData = params.get('data');
if (!parentUrl) throw new Error('No parent.');
if (!encodedData) throw new Error('No data.');
let parsedParent;
try {
parsedParent = new URL(parentUrl);
} catch (_error) {
throw new Error('Invalid parent.');
}
let webauthnJson;
if (params.get('v') === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
} else {
let payload;
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('Cannot parse data.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('Cannot parse data.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
}
return {
parentUrl,
parentProtocol: parsedParent.protocol.toLowerCase(),
parentOrigin: parsedParent.origin,
webauthnJson,
buttonText: safeDecodeURIComponent(params.get('btnText')),
awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.');
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions: typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: base64UrlFromBuffer(response.clientDataJSON),
signature: base64UrlFromBuffer(response.signature),
},
});
}
function normalizeAllowedOrigin(value) {
try {
const url = new URL(String(value || ''));
return url.protocol && url.host ? `${url.protocol}//${url.host}` : '';
} catch (_error) {
return '';
}
}
function isExtensionOrigin(origin) {
return origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://');
}
export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) {
if (request.parentProtocol === 'file:') {
return { eventOrigin: 'null', targetOrigin: request.parentUrl };
}
const parentOrigin = normalizeAllowedOrigin(request.parentUrl);
if (!parentOrigin) throw new Error('Invalid parent.');
if (parentOrigin === connectorOrigin) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) {
return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl };
}
const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean);
if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) {
return { eventOrigin: parentOrigin, targetOrigin: parentOrigin };
}
throw new Error('Untrusted parent.');
}
async function loadAllowedParentOrigins() {
try {
const response = await fetch('/api/web-bootstrap', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
if (!response.ok) return [];
const body = await response.json();
return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : [];
} catch (_error) {
return [];
}
}
function browserErrorMessage(error) {
return error?.message || String(error || 'WebAuthn failed.');
}
async function initializePage() {
const button = document.getElementById('webauthn-button');
if (!button) return;
let request;
let publicKey;
let channel;
let stopWebAuthn = false;
let sentSuccess = false;
let running = false;
const defaultText = 'Read security key';
const awaitingDefaultText = 'Awaiting security key interaction...';
function setButton(awaiting = false) {
button.textContent = awaiting
? request?.awaitingText || awaitingDefaultText
: request?.buttonText || defaultText;
button.setAttribute('aria-disabled', awaiting ? 'true' : 'false');
button.setAttribute('aria-busy', awaiting ? 'true' : 'false');
button.onclick = awaiting ? null : executeWebAuthn;
}
function post(message) {
window.parent.postMessage(message, channel.targetOrigin);
}
function reportError(error) {
if (channel) post(`error|${browserErrorMessage(error)}`);
setButton(false);
}
async function executeWebAuthn() {
if (running || sentSuccess) return;
if (stopWebAuthn) {
stopWebAuthn = false;
setButton(false);
return;
}
running = true;
setButton(true);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No security key was selected.');
if (sentSuccess) return;
post(`success|${buildCredentialData(credential)}`);
sentSuccess = true;
} catch (error) {
reportError(error);
} finally {
running = false;
}
}
try {
request = parseConnectorRequest(window.location.search);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins());
setButton(false);
} catch (error) {
button.textContent = browserErrorMessage(error);
button.setAttribute('aria-disabled', 'true');
return;
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
reportError(new Error('WebAuthn is not supported in this browser.'));
return;
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || event.origin !== channel.eventOrigin) return;
if (event.data === 'stop') {
stopWebAuthn = true;
setButton(false);
} else if (event.data === 'start' && stopWebAuthn) {
stopWebAuthn = false;
void executeWebAuthn();
}
});
post('info|ready');
const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome');
if (!isSafari) void executeWebAuthn();
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true });
} else {
void initializePage();
}
}
@@ -0,0 +1,422 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px 18px;
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.remember {
display: flex;
align-items: center;
gap: 9px;
color: #344054;
font-size: 14px;
}
.remember input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="title">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="title">Verify your identity</h1>
<p id="subtitle">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="msg" class="msg" role="status" aria-live="polite"></div>
<label class="remember">
<input id="remember" type="checkbox" />
<span id="remember-label">Trust this device for 30 days</span>
</label>
<button id="webauthn-button" type="button">Read security key</button>
</div>
</section>
</main>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
var titleEl = document.getElementById("title");
var subtitleEl = document.getElementById("subtitle");
var rememberEl = document.getElementById("remember");
var rememberLabelEl = document.getElementById("remember-label");
var buttonEl = document.getElementById("webauthn-button");
var msgEl = document.getElementById("msg");
titleEl.textContent = text.title;
subtitleEl.textContent = text.subtitle;
rememberLabelEl.textContent = text.remember;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
buttonEl.addEventListener("click", start);
function pickText(locale) {
var normalized = String(locale || "en").toLowerCase();
if (normalized.indexOf("zh") === 0) {
return {
title: "\u9a8c\u8bc1\u8eab\u4efd",
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
success: "\u9a8c\u8bc1\u5b8c\u6210",
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
};
}
return {
title: "Verify your identity",
subtitle: "Use your security key to finish two-step verification.",
remember: "Trust this device for 30 days",
button: "Read security key",
awaiting: "Awaiting security key interaction...",
success: "Verification complete",
unsupported: "This browser does not support security keys",
};
}
function decodeRepeated(value) {
if (!value) return "";
var out = String(value);
for (var i = 0; i < 2; i += 1) {
try {
var next = decodeURIComponent(out);
if (next === out) break;
out = next;
} catch (_error) {
break;
}
}
return out;
}
function normalizeOrigin(value) {
if (!value) return "";
try {
var url = new URL(value);
if (!url.protocol || !url.host) return "";
return url.protocol + "//" + url.host;
} catch (_error) {
return "";
}
}
function isExtensionOrigin(origin) {
return (
origin.indexOf("chrome-extension://") === 0 ||
origin.indexOf("moz-extension://") === 0 ||
origin.indexOf("safari-web-extension://") === 0
);
}
function allowedParentOrigins() {
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
headers: { Accept: "application/json" },
credentials: "omit",
}).then(function (response) {
if (!response.ok) return [];
return response.json();
}).then(function (body) {
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
? body.webAuthnAllowedOrigins
: [];
return origins.map(normalizeOrigin).filter(Boolean);
}).catch(function () {
return [];
});
return allowedParentOriginsPromise;
}
function trustedParentOrigin(allowedOrigins) {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
var parentOrigin = normalizeOrigin(parent);
if (!parentOrigin) return "";
if (parentOrigin === window.location.origin) {
return parentOrigin;
}
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
return parentOrigin;
}
return "";
}
function safeShallowCopy(source) {
var copy = {};
if (!source || typeof source !== "object") return copy;
Object.keys(source).forEach(function (key) {
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
copy[key] = source[key];
});
return copy;
}
async function postResult(message) {
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin);
}
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, parentOrigin);
}
}
window.postMessage(message, window.location.origin);
}
function showMessage(kind, message) {
msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind;
}
function decodeBase64Unicode(value) {
var input = String(value || "").replace(/ /g, "+");
try {
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
} catch (_error) {
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
}
}
function bytesFromBase64Url(value) {
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
var binary = atob(normalized);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function base64UrlFromBuffer(value) {
if (!value) return undefined;
var bytes = value instanceof Uint8Array
? value
: new Uint8Array(value);
var binary = "";
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function readPublicKeyOptions() {
var data = params.get("data");
if (!data) throw new Error("No data.");
var decoded = decodeBase64Unicode(data);
if (params.get("v") === "1") {
return JSON.parse(decoded);
}
var payload = JSON.parse(decoded);
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = safeShallowCopy(options);
copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
var next = safeShallowCopy(credential);
next.id = bytesFromBase64Url(credential && credential.id);
return next;
});
}
return copy;
}
function credentialToDataString(credential) {
var response = credential.response;
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
var data = {
id: credential.id,
rawId: base64UrlFromBuffer(credential.rawId),
type: credential.type,
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientDataJSON,
clientDataJSON: clientDataJSON,
signature: base64UrlFromBuffer(response.signature),
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
},
};
return JSON.stringify(data);
}
async function start() {
if (sentSuccess) return;
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
showMessage("error", text.unsupported);
return;
}
try {
msgEl.className = "msg";
buttonEl.disabled = true;
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
var publicKey = normalizeOptions(readPublicKeyOptions());
var credential = await navigator.credentials.get({ publicKey: publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
await postResult({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
});
sentSuccess = true;
showMessage("success", text.success);
} catch (error) {
buttonEl.disabled = false;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
}
}
})();
</script>
</body>
</html>
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#f6f8fb" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
min-height: 100svh;
place-items: center;
padding: max(28px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled)[aria-disabled="false"] {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled)[aria-disabled="false"] {
transform: translateY(1px);
}
button:disabled,
button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.62;
}
button[data-state="return"] {
cursor: pointer;
opacity: 1;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.info {
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="webauthn-header">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="webauthn-header">Verify your identity</h1>
<p id="webauthn-copy">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="webauthn-status" class="msg" role="status" aria-live="polite" hidden></div>
<button id="webauthn-button" type="button" data-state="loading" aria-busy="true" aria-disabled="true">
Preparing passkey…
</button>
</div>
</section>
</main>
<script type="module" src="/webauthn-mobile-connector.js"></script>
</body>
</html>
+272
View File
@@ -0,0 +1,272 @@
const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback';
const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com'];
function safeDecodeURIComponent(value) {
let decoded = String(value || '');
for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) {
try {
decoded = decodeURIComponent(decoded);
} catch (_error) {
break;
}
}
return decoded;
}
export function decodeBase64Utf8(value) {
let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
let binary;
try {
binary = atob(normalized);
} catch (_error) {
throw new Error('The WebAuthn challenge is not valid Base64.');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes);
return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
}
export function bytesFromBase64Url(value) {
let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
try {
return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0));
} catch (_error) {
throw new Error('The WebAuthn challenge contains invalid binary data.');
}
}
export function base64UrlFromBuffer(value) {
if (value == null) return undefined;
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function officialAppLinkHost(hostname) {
const normalized = String(hostname || '').toLowerCase();
return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com';
}
export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) {
// Match Bitwarden's connector protocol: the scheme parameter governs the
// callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom
// scheme; a client-provided callbackUri is only a mobile-flow signal.
if (deeplinkScheme) {
return String(deeplinkScheme).toLowerCase() === 'https'
? `https://${officialAppLinkHost(hostname)}/webauthn-callback`
: CUSTOM_SCHEME_CALLBACK;
}
return payload?.mobile === true || payload?.callbackUri != null || legacyMobile
? CUSTOM_SCHEME_CALLBACK
: null;
}
export function parseConnectorRequest(search, hostname = '') {
const params = search instanceof URLSearchParams
? search
: new URLSearchParams(String(search || '').replace(/^\?/, ''));
const encodedData = params.get('data');
if (!encodedData) throw new Error('No WebAuthn challenge was provided.');
const version = params.get('v');
let payload = null;
let webauthnJson;
let headerText;
let buttonText;
let returnButtonText;
let awaitingText;
if (version === '1') {
webauthnJson = decodeBase64Utf8(encodedData);
headerText = params.get('headerText');
buttonText = params.get('btnText');
returnButtonText = params.get('btnReturnText');
awaitingText = params.get('btnAwaitingInteractionText');
} else {
try {
payload = JSON.parse(decodeBase64Utf8(encodedData));
} catch (_error) {
throw new Error('The WebAuthn challenge could not be decoded.');
}
if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) {
throw new Error('The WebAuthn challenge is incomplete.');
}
webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data);
headerText = payload.headerText;
buttonText = payload.btnText;
returnButtonText = payload.btnReturnText;
awaitingText = payload.btnAwaitingInteractionText;
}
const callbackUri = resolveMobileCallbackUri({
deeplinkScheme: params.get('deeplinkScheme'),
payload,
hostname,
legacyMobile: params.get('client') === 'mobile',
});
if (!callbackUri) throw new Error('No supported mobile return target was provided.');
return {
callbackUri,
webauthnJson,
headerText: safeDecodeURIComponent(headerText),
buttonText: safeDecodeURIComponent(buttonText),
returnButtonText: safeDecodeURIComponent(returnButtonText),
awaitingText: safeDecodeURIComponent(awaitingText),
};
}
export function normalizePublicKeyOptions(webauthnJson) {
const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson;
if (!source || typeof source !== 'object' || !source.challenge) {
throw new Error('The WebAuthn challenge is invalid.');
}
const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) };
if (Array.isArray(source.allowCredentials)) {
publicKey.allowCredentials = source.allowCredentials.map((credential) => ({
...credential,
id: bytesFromBase64Url(credential?.id),
}));
}
return publicKey;
}
export function buildCredentialData(assertedCredential) {
const response = assertedCredential?.response;
if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) {
throw new Error('The authenticator returned an incomplete response.');
}
const extensions = typeof assertedCredential.getClientExtensionResults === 'function'
? assertedCredential.getClientExtensionResults()
: {};
const clientData = base64UrlFromBuffer(response.clientDataJSON);
return JSON.stringify({
id: assertedCredential.id,
rawId: base64UrlFromBuffer(assertedCredential.rawId),
type: assertedCredential.type,
extensions,
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientData,
signature: base64UrlFromBuffer(response.signature),
},
});
}
export function buildCallbackUrl(callbackUri, key, value) {
const separator = String(callbackUri).includes('?') ? '&' : '?';
return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`;
}
function translations(locale) {
const normalized = String(locale || 'en').toLowerCase();
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) {
return {
title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證',
awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。',
};
}
if (normalized.startsWith('zh')) {
return {
title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证',
awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden',
unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。',
};
}
return {
title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.',
button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…',
returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden',
unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.',
};
}
function browserErrorMessage(error, text) {
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled;
return error?.message || String(error || 'WebAuthn failed.');
}
function initializePage() {
const button = document.getElementById('webauthn-button');
const header = document.getElementById('webauthn-header');
const copy = document.getElementById('webauthn-copy');
const status = document.getElementById('webauthn-status');
if (!button || !header || !copy || !status) return;
const text = translations(navigator.languages?.[0] || navigator.language);
document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en';
copy.textContent = text.copy;
let request;
let publicKey;
let completed = false;
let returnUri = '';
function setButton(label, state, handler) {
button.textContent = label;
button.dataset.state = state;
button.disabled = state === 'unavailable';
button.setAttribute('aria-disabled', handler ? 'false' : 'true');
button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false');
button.onclick = handler;
}
function setStatus(kind, message) {
status.hidden = !message;
status.dataset.kind = kind;
status.textContent = message || '';
status.className = message ? `msg show ${kind}` : 'msg';
}
function navigate(uri) {
returnUri = uri;
window.location.replace(uri);
setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri));
}
function handoffError(message) {
setStatus('error', message);
if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message));
}
async function executeWebAuthn() {
if (completed || button.dataset.state === 'waiting') return;
setStatus('info', request.awaitingText || text.awaiting);
setButton(request.awaitingText || text.awaiting, 'waiting', null);
try {
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('No passkey was selected.');
const data = buildCredentialData(credential);
completed = true;
setStatus('success', text.returning);
navigate(buildCallbackUrl(request.callbackUri, 'data', data));
} catch (error) {
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
handoffError(browserErrorMessage(error, text));
}
}
try {
request = parseConnectorRequest(window.location.search, window.location.hostname);
publicKey = normalizePublicKeyOptions(request.webauthnJson);
header.textContent = request.headerText || text.title;
setButton(request.buttonText || text.button, 'ready', executeWebAuthn);
} catch (error) {
header.textContent = text.title;
setStatus('error', browserErrorMessage(error, text));
setButton(text.button, 'unavailable', null);
}
if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) {
handoffError(text.unsupported);
if (!request?.callbackUri) setButton(text.button, 'unavailable', null);
}
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true });
else initializePage();
}
+243 -46
View File
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage';
import {
createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier,
@@ -19,7 +20,7 @@ import {
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
getTotpStatus,
getTwoFactorProviderStatus,
getVaultRevisionDate,
saveSession,
stripProfileSecrets,
@@ -57,6 +58,7 @@ import {
type PendingPasskeyPassword,
type PendingTotp,
} from '@/lib/app-auth';
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions';
@@ -66,6 +68,7 @@ import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import {
@@ -109,6 +112,8 @@ const APP_ROUTE_PATHS = [
'/',
'/vault',
'/vault/totp',
'/security/password-health',
'/generator',
'/sends',
'/admin',
'/logs',
@@ -151,6 +156,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
@@ -224,6 +231,7 @@ export default function App() {
hint: null,
});
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
@@ -237,6 +245,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -244,6 +253,8 @@ export default function App() {
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false);
@@ -260,10 +271,16 @@ export default function App() {
const [vaultDecryptError, setVaultDecryptError] = useState('');
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const lockedSessionRetryAttemptRef = useRef(0);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -285,15 +302,16 @@ export default function App() {
}, [pushToast]);
useEffect(() => {
const syncInviteFromUrl = () => {
const syncUrlState = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl());
setHashPathRaw(window.location.hash || '');
};
syncInviteFromUrl();
window.addEventListener('hashchange', syncInviteFromUrl);
window.addEventListener('popstate', syncInviteFromUrl);
syncUrlState();
window.addEventListener('hashchange', syncUrlState);
window.addEventListener('popstate', syncUrlState);
return () => {
window.removeEventListener('hashchange', syncInviteFromUrl);
window.removeEventListener('popstate', syncInviteFromUrl);
window.removeEventListener('hashchange', syncUrlState);
window.removeEventListener('popstate', syncUrlState);
};
}, []);
@@ -373,6 +391,10 @@ export default function App() {
}
}, [phase, profile, session]);
useEffect(() => {
if (phase !== 'app') clearPasswordSecurityCache();
}, [phase]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
@@ -484,13 +506,15 @@ export default function App() {
if (phase !== 'locked' || !session) return;
if (IS_DEMO_MODE) return;
let cancelled = false;
let retryTimerId: number | null = null;
void (async () => {
const result = await hydrateLockedSession(session, profile);
if (cancelled) return;
if (!result.session) {
if (result.kind === 'expired') {
setSession(null);
setProfile(null);
setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('login');
if (location !== '/login') navigate('/login');
return;
@@ -499,16 +523,57 @@ export default function App() {
if (result.profile) {
setProfile(stripProfileSecrets(result.profile));
}
if (result.kind === 'transient') {
setUnlockPreparing(false);
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
lockedSessionRetryAttemptRef.current += 1;
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
retryTimerId = window.setTimeout(() => {
setLockedSessionRetryKey((value) => value + 1);
}, retryAfterMs);
return;
}
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
})();
return () => {
cancelled = true;
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
};
}, [phase, session?.email, location, navigate]);
}, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
useEffect(() => {
if (!lockedSessionRefreshError || phase !== 'locked') return;
const retryNow = () => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRetryKey((value) => value + 1);
};
const handleVisibility = () => {
if (document.visibilityState === 'visible') retryNow();
};
window.addEventListener('online', retryNow);
document.addEventListener('visibilitychange', handleVisibility);
return () => {
window.removeEventListener('online', retryNow);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, [lockedSessionRefreshError, phase]);
async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session);
setProfile(login.profile);
setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPendingTotp(null);
setPendingTotpMode(null);
setPendingPasskeyPassword(null);
@@ -639,19 +704,38 @@ export default function App() {
}
}
function handleSelectTotpProvider(providerType: number) {
if (totpSubmitting) return;
setPendingTotp((current) => {
if (!current || current.providerType === providerType) return current;
const canUseProvider = current.availableProviders.includes(providerType);
if (!canUseProvider) return current;
return {
...current,
providerType,
providerData: current.providerDataByType[providerType],
};
});
setTotpCode('');
}
async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', t('txt_please_input_totp_code'));
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
if (!isPasskeyTwoFactor && !totpCode.trim()) {
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
return;
}
setTotpSubmitting(true);
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
const token = isPasskeyTwoFactor
? await assertTwoFactorPasskey(pendingTotp.providerData)
: totpCode;
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
await finalizeLogin(login);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
} finally {
setTotpSubmitting(false);
}
@@ -832,11 +916,13 @@ export default function App() {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
clearPasswordSecurityCache();
setUnlockPassword('');
setPendingTotp(null);
setPendingTotpMode(null);
setTotpCode('');
setUnlockPreparing(false);
setLockedSessionRefreshError('');
setPhase('locked');
navigate('/lock');
}
@@ -853,6 +939,7 @@ export default function App() {
setSession(null);
clearProfileSnapshot();
clearOfflineUnlockRecord();
clearPasswordSecurityCache();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
@@ -936,11 +1023,14 @@ export default function App() {
confirm={null}
onCancelConfirm={() => {}}
pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
@@ -1066,9 +1156,9 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const totpStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch),
const twoFactorStatusQuery = useQuery({
queryKey: ['two-factor-status', vaultCacheKey || session?.email],
queryFn: () => getTwoFactorProviderStatus(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
@@ -1085,18 +1175,38 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey,
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000,
refetchInterval: 15_000,
refetchIntervalInBackground: true,
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1105,11 +1215,11 @@ export default function App() {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch();
} finally {
@@ -1125,6 +1235,7 @@ export default function App() {
requestApproved: false,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch();
} finally {
@@ -1189,13 +1300,25 @@ export default function App() {
if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile);
void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => {
if (session?.accessToken) return;
repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]);
@@ -1767,11 +1890,13 @@ export default function App() {
onNotify: pushToast,
onProfileUpdated: setProfile,
onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch,
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
});
const adminActions = useAdminActions({
authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast,
onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch,
@@ -1788,7 +1913,6 @@ export default function App() {
await pendingAuthRequestsQuery.refetch();
};
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -1827,13 +1951,17 @@ export default function App() {
const mobilePrimaryRoute =
location === '/sends'
? '/sends'
: location === '/generator'
? '/generator'
: location === '/vault/totp'
? '/vault/totp'
: location === '/vault'
? '/vault'
: '/settings';
const currentPageTitle = (() => {
if (location === '/security/password-health') return t('txt_password_security');
if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/generator') return t('txt_password_generator');
if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center');
@@ -1890,6 +2018,7 @@ export default function App() {
session,
mobileLayout,
mobileSidebarToggleKey,
themePreference,
importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1904,10 +2033,13 @@ export default function App() {
invites: invitesQuery.data || [],
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
totpEnabled: !!totpStatusQuery.data?.enabled,
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [],
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -1916,6 +2048,7 @@ export default function App() {
onNavigate: navigate,
onLogout: handleLogout,
onNotify: pushToast,
onThemePreferenceChange: setThemePreference,
onImport: vaultSendActions.importVault,
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
onExport: vaultSendActions.exportVault,
@@ -1950,11 +2083,20 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => {
await accountSecurityActions.enableTotp(secret, token);
await totpStatusQuery.refetch();
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token, masterPassword);
await twoFactorStatusQuery.refetch();
},
onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
onDisableYubiKey: accountSecurityActions.disableYubiKey,
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
@@ -1962,12 +2104,16 @@ export default function App() {
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
onRefreshTwoFactorStatus: async () => {
await twoFactorStatusQuery.refetch();
},
pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
onApproveAuthRequest: approveAuthRequest,
onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1980,34 +2126,70 @@ export default function App() {
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin,
onCreateInvite: adminActions.createInvite,
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
onDeleteAllInvites: adminActions.deleteAllInvites,
onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite,
onDeleteInvite: adminActions.deleteInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(),
staleTime: 30_000,
}),
onSaveBackupSettings: backupActions.saveSettings,
onRunRemoteBackup: backupActions.runRemoteBackup,
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.inspectRemoteBackup(hash, destinationId, path);
},
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path);
},
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
};
const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2078,6 +2260,7 @@ export default function App() {
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
unlockReady={!!session?.email}
unlockPreparing={unlockPreparing}
sessionRefreshError={lockedSessionRefreshError}
loginValues={loginValues}
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
passkeyPassword={passkeyPassword}
@@ -2118,6 +2301,11 @@ export default function App() {
onLogout={logoutNow}
onTogglePasswordHint={() => void handleTogglePasswordHint()}
onShowLockedPasswordHint={handleShowLockedPasswordHint}
onRetrySessionRefresh={() => {
lockedSessionRetryAttemptRef.current = 0;
setLockedSessionRefreshError('');
setLockedSessionRetryKey((value) => value + 1);
}}
/>
<AppGlobalOverlays
toasts={toasts}
@@ -2125,11 +2313,14 @@ export default function App() {
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp}
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
totpCode={totpCode}
rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode}
onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()}
onSelectTotpProvider={handleSelectTotpProvider}
onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null);
@@ -2184,11 +2375,14 @@ export default function App() {
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
@@ -2215,21 +2409,24 @@ export default function App() {
/>
<AuthRequestApprovalDialog
open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest}
authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId}
onApprove={() => {
if (!latestPendingAuthRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
if (!authRequestDialogRequest) return;
void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onDeny={() => {
if (!latestPendingAuthRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
if (!authRequestDialogRequest) return;
void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/>
</>
);
+9 -7
View File
@@ -13,10 +13,11 @@ interface AdminPageProps {
error: string;
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onDeleteInvite: (code: string) => Promise<void>;
}
export default function AdminPage(props: AdminPageProps) {
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
<h3>{t('txt_invites')}</h3>
<div className="actions admin-invites-head-actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteInvalidInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_invalid')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
@@ -184,11 +188,9 @@ export default function AdminPage(props: AdminPageProps) {
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
</button>
)}
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</td>
</tr>
+63 -155
View File
@@ -1,6 +1,6 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import { ArrowUpDown, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useState } from 'preact/hooks';
import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
@@ -28,19 +28,32 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps;
}
type NavLayoutMode = 'flat' | 'grouped-expanded' | 'grouped-smart';
const NAV_GROUPS_STORAGE_KEY = 'nodewarden.navGroups';
const NAV_LAYOUT_STORAGE_KEY = 'nodewarden.navLayoutMode';
const DEFAULT_EXPANDED_GROUPS = {
tools: true,
settings: true,
management: true,
};
function readNavLayoutMode(): NavLayoutMode {
if (typeof window === 'undefined') return 'flat';
type NavGroup = keyof typeof DEFAULT_EXPANDED_GROUPS;
type ExpandedGroups = Record<NavGroup, boolean>;
function readExpandedGroups(): ExpandedGroups {
if (typeof window === 'undefined') return DEFAULT_EXPANDED_GROUPS;
try {
const saved = window.localStorage.getItem(NAV_LAYOUT_STORAGE_KEY);
if (saved === 'flat' || saved === 'grouped-expanded' || saved === 'grouped-smart') return saved;
const saved = window.localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (!saved) return DEFAULT_EXPANDED_GROUPS;
const parsed = JSON.parse(saved) as Partial<ExpandedGroups>;
return {
tools: typeof parsed.tools === 'boolean' ? parsed.tools : DEFAULT_EXPANDED_GROUPS.tools,
settings: typeof parsed.settings === 'boolean' ? parsed.settings : DEFAULT_EXPANDED_GROUPS.settings,
management: typeof parsed.management === 'boolean' ? parsed.management : DEFAULT_EXPANDED_GROUPS.management,
};
} catch {
// Ignore local preference read failures.
}
return 'flat';
return DEFAULT_EXPANDED_GROUPS;
}
function isAdminProfile(profile: Profile | null): boolean {
@@ -55,57 +68,19 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute;
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
const [expandedGroups, setExpandedGroups] = useState({
vault: true,
settings: false,
data: false,
management: false,
});
const [expandedGroups, setExpandedGroups] = useState<ExpandedGroups>(readExpandedGroups);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!navLayoutPickerOpen) return;
const target = event.target as Node | null;
if (navLayoutPickerRef.current && target && !navLayoutPickerRef.current.contains(target)) {
setNavLayoutPickerOpen(false);
function toggleGroup(group: NavGroup): void {
setExpandedGroups((current) => {
const next = { ...current, [group]: !current[group] };
try {
window.localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch {
// Ignore local preference write failures.
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setNavLayoutPickerOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [navLayoutPickerOpen]);
function setNavMode(mode: NavLayoutMode): void {
setNavLayoutMode(mode);
setNavLayoutPickerOpen(false);
try {
window.localStorage.setItem(NAV_LAYOUT_STORAGE_KEY, mode);
} catch {
// Ignore local preference write failures.
}
}
function toggleGroup(group: keyof typeof expandedGroups): void {
setExpandedGroups((current) => ({ ...current, [group]: !current[group] }));
}
function groupOpen(group: keyof typeof expandedGroups, active: boolean): boolean {
if (navLayoutMode === 'grouped-expanded') return true;
return expandedGroups[group] || active;
return next;
});
}
function renderSideLink(href: string, active: boolean, icon: ComponentChildren, label: string) {
@@ -126,18 +101,17 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
}
function renderNavGroup(
group: keyof typeof expandedGroups,
group: NavGroup,
title: string,
icon: ComponentChildren,
active: boolean,
children: ComponentChildren
) {
const open = groupOpen(group, active);
const open = expandedGroups[group];
return (
<div className={`side-nav-group ${open ? 'open' : ''}`}>
<button
type="button"
className={`side-group-trigger ${active ? 'active' : ''}`}
className="side-group-trigger"
aria-expanded={open}
onClick={() => toggleGroup(group)}
>
@@ -154,81 +128,42 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
);
}
const navLayoutOptions: Array<{ mode: NavLayoutMode; label: string }> = [
{
mode: 'flat',
label: t('txt_nav_layout_flat'),
},
{
mode: 'grouped-expanded',
label: t('txt_nav_layout_grouped_expanded'),
},
{
mode: 'grouped-smart',
label: t('txt_nav_layout_grouped_smart'),
},
];
const navLayoutLabel = navLayoutOptions.find((option) => option.mode === navLayoutMode)?.label || t('txt_nav_layout_flat');
const flatNav = (
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</>
);
const groupedNav = (
<>
{renderNavGroup(
'vault',
t('nav_my_vault'),
<KeyRound size={16} />,
vaultActive,
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
</>
)}
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup(
'settings',
t('txt_settings'),
<SettingsIcon size={16} />,
settingsActive,
'tools',
t('nav_group_tools'),
<Sparkles size={16} />,
<>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{renderNavGroup(
'data',
t('nav_group_data_backup'),
<Cloud size={16} />,
dataActive,
<>
{isAdmin && renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/generator', props.location === '/generator', t('nav_generator'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
{renderSubLink(props.importRoute, props.isImportRoute, t('nav_import_export'))}
</>
)}
{renderNavGroup(
'management',
t('nav_group_management'),
<ShieldUser size={16} />,
managementActive,
'settings',
t('txt_settings'),
<SettingsIcon size={16} />,
<>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
</>
)}
{isAdmin &&
renderNavGroup(
'management',
t('nav_group_system_management'),
<ShieldUser size={16} />,
<>
{renderSubLink('/backup', props.location === '/backup', t('nav_backup_strategy'))}
{renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
</>
)}
</>
);
@@ -277,38 +212,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<div className="app-main">
<aside className="app-side">
<div className="side-nav-main">
{navLayoutMode === 'flat' ? flatNav : groupedNav}
</div>
<div className="nav-layout-control" ref={navLayoutPickerRef}>
{navLayoutPickerOpen && (
<div className="nav-layout-menu" role="menu">
{navLayoutOptions.map((option) => (
<button
key={option.mode}
type="button"
className={`nav-layout-option ${navLayoutMode === option.mode ? 'active' : ''}`}
onClick={() => setNavMode(option.mode)}
role="menuitemradio"
aria-checked={navLayoutMode === option.mode}
>
<span className="nav-layout-option-text">
<strong>{option.label}</strong>
</span>
{navLayoutMode === option.mode && <Check size={15} className="nav-layout-check" />}
</button>
))}
</div>
)}
<button
type="button"
className={`nav-layout-trigger ${navLayoutPickerOpen ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={navLayoutPickerOpen}
onClick={() => setNavLayoutPickerOpen((open) => !open)}
title={t('txt_nav_layout')}
>
<SlidersHorizontal size={15} />
</button>
{groupedNav}
</div>
</aside>
<main className="content">
@@ -327,6 +231,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Clock3 size={18} />
<span>{t('txt_verification_code')}</span>
</Link>
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
<Sparkles size={18} />
<span>{t('nav_generator')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} />
<span>{t('nav_sends')}</span>
+121 -12
View File
@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import { t } from '@/lib/i18n';
@@ -11,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string;
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
/** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void;
}
@@ -21,11 +24,14 @@ interface AppGlobalOverlaysProps {
confirm: AppConfirmState | null;
onCancelConfirm: () => void;
pendingTotpOpen: boolean;
pendingTotpProviderType?: number;
pendingTotpAvailableProviders?: number[];
totpCode: string;
rememberDevice: boolean;
onTotpCodeChange: (value: string) => void;
onRememberDeviceChange: (checked: boolean) => void;
onConfirmTotp: () => void;
onSelectTotpProvider: (providerType: number) => void;
onCancelTotp: () => void;
onUseRecoveryCode: () => void;
totpSubmitting: boolean;
@@ -37,7 +43,46 @@ interface AppGlobalOverlaysProps {
disableTotpSubmitting: boolean;
}
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_ORDER = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
const available = new Set(providerTypes || []);
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
}
function twoFactorProviderLabel(providerType: number): string {
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
return t('txt_authenticator_app');
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders]
);
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => {
setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return (
<>
<ConfirmDialog
@@ -49,16 +94,43 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/>
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</ConfirmDialog>
<ConfirmDialog
open={props.pendingTotpOpen}
title={t('txt_two_step_verification')}
message={t('txt_password_is_already_verified')}
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
<span className="dialog-title-stack">
<span>{t('txt_two_step_verification')}</span>
<span>{t('txt_passkey')}</span>
</span>
) : t('txt_two_step_verification')}
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
confirmText={t('txt_verify')}
cancelText={t('txt_cancel')}
hideCancel
closeButton
showIcon={false}
confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting}
@@ -67,16 +139,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
afterActions={(
<div className="dialog-extra">
<div className="dialog-divider" />
{alternateProviders.length > 0 && (
<div className="two-factor-method-switcher">
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.totpSubmitting}
aria-expanded={methodChooserOpen}
onClick={() => setMethodChooserOpen((open) => !open)}
>
{t('txt_select_another_verification_method')}
</button>
{methodChooserOpen && (
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
{alternateProviders.map((providerType) => (
<button
key={providerType}
type="button"
className="btn btn-secondary two-factor-method-option"
disabled={props.totpSubmitting}
onClick={() => {
setMethodChooserOpen(false);
props.onSelectTotpProvider(providerType);
}}
>
{twoFactorProviderLabel(providerType)}
</button>
))}
</div>
)}
</div>
)}
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')}
</button>
</div>
)}
>
<label className="field">
<span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
{isWebAuthn ? (
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
) : (
<label className="field">
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
)}
<label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span>
@@ -88,7 +196,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')}
hideCancel
closeButton
danger
showIcon={false}
confirmDisabled={props.disableTotpSubmitting}

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