510 Commits

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

Mark the app root as non-translatable and keep the document language synchronized with the active locale to reduce browser translation DOM mutations.
2026-06-06 19:30:35 +08:00
shuaiplus 2d2cbea530 fix: add .tmp-bitwarden-clients/ to .gitignore 2026-05-31 21:23:53 +08:00
shuaiplus 4f5d992f10 fix: enhance cipher handling with repairable URI support and sync improvements 2026-05-31 19:53:42 +08:00
52assert 667afa305b fix(deploy): make KV deploy idempotent
Adapted from #233 with deploy build kept in wrangler config.
2026-05-31 01:20:14 +08:00
shuaiplus 85bd2fa4bf fix: streamline deployment commands in configuration files 2026-05-31 01:15:00 +08:00
shuaiplus fd9707c396 fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling 2026-05-31 01:03:32 +08:00
shuaiplus 192071e4a7 fix: enhance cipher key handling and compatibility for secure notes 2026-05-30 02:43:09 +08:00
shuaiplus fcf7c80daa fix: adjust input padding for improved layout in forms and responsive styles 2026-05-30 02:34:45 +08:00
shuaiplus ed9251c014 fix: enhance compatibility for cipher login normalization and uri handling 2026-05-30 02:26:36 +08:00
shuaiplus a75955ca6d fix: update password verification to support legacy client hashes 2026-05-23 23:07:10 +08:00
shuaiplus 03f7fbf601 fix: repair mixed cipher key encryption handling 2026-05-23 12:43:44 +08:00
shuaiplus a63336764f fix: improve lock timeout retrieval by handling null and empty values 2026-05-23 03:19:49 +08:00
shuaiplus f56d7f01ca fix: add content length validation and timeout handling for icon fetching 2026-05-23 03:17:24 +08:00
shuaiplus 8ff60aed24 fix: remove unused change password handling functions from public route 2026-05-23 03:08:21 +08:00
shuaiplus 749de4e2e1 fix: update server hash prefix handling for password hashing and verification 2026-05-23 03:00:58 +08:00
shuaiplus ea9e238aa7 fix: remove checks for portable admins in backup settings saving and normalization 2026-05-23 02:53:03 +08:00
shuaiplus 22d267f5bc fix: remove unused saveRefreshTokenRecord parameter from getRefreshTokenRecord 2026-05-23 02:42:08 +08:00
shuaiplus 18eefd1174 fix: simplify login identifier construction in two-factor recovery and token handling 2026-05-23 02:22:04 +08:00
shuaiplus d468745841 fix: restore ip-scoped password login lockout 2026-05-23 02:12:40 +08:00
shuaiplus 970621c459 fix: remove optional TOTP_SECRET from environment bindings 2026-05-23 02:07:59 +08:00
shuaiplus 385a873e65 fix: improve device validation logic in refresh token handling 2026-05-23 02:00:41 +08:00
shuaiplus 56185ecb69 fix: strip plaintext login helpers from cipher payload 2026-05-23 01:49:34 +08:00
shuaiplus 04ebfc7021 feat: refactor cipher login data type for improved clarity 2026-05-18 02:13:01 +08:00
shuaiplus c50247b8fe feat: add URI checksum repair functionality for ciphers 2026-05-18 01:59:02 +08:00
shuaiplus 776408e9d0 feat: enhance SSH key handling with Ed25519 support and PEM formatting 2026-05-16 16:34:06 +08:00
shuaiplus e641da517d feat: add uriChecksum handling and sha256Base64 function for enhanced security 2026-05-16 16:22:43 +08:00
shuaiplus b7878ffe01 feat: improve scrollbar styles and dark mode compatibility 2026-05-15 19:12:40 +08:00
shuaiplus bbad9d60a7 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-05-15 18:28:09 +08:00
shuaiplus ed58467766 feat: enhance authorized devices table layout and styling 2026-05-15 18:28:05 +08:00
agesky 2f911e66a6 Update README.md
修改一处描述错误
2026-05-15 11:12:47 +08:00
shuaiplus d06e050162 feat: Updated visual rapid deployment instructions, added JWT_SECRET settings and Workers custom domain prompts 2026-05-14 22:54:54 +08:00
shuaiplus d0dc31ce86 feat: enhance attachment metadata handling and add change password URI support 2026-05-14 22:46:29 +08:00
shuaiplus f64abaa75d feat: enhance search functionality by including cipher ID in search text 2026-05-14 10:52:11 +08:00
shuaiplus 7312086f92 feat: add restore functionality for deleted items with corresponding UI updates 2026-05-14 10:40:32 +08:00
shuaiplus 3e4c104e1d feat: added logging system 2026-05-14 02:42:15 +08:00
shuaiplus 17ceec45b1 feat: implement user and device cache invalidation in AuthService 2026-05-12 19:12:53 +08:00
shuaiplus 2685741386 feat: add permanent trust functionality for devices with corresponding API and UI updates 2026-05-12 18:01:04 +08:00
shuaiplus 83a1fc2376 feat: enhance TOTP settings UI with improved layout and status indication 2026-05-12 15:55:05 +08:00
shuaiplus 06431c4145 feat: enhance mobile responsiveness for management routes and table layout 2026-05-12 15:16:17 +08:00
shuaiplus 700910099b feat: adjust eye button positioning and hover effect for password toggle 2026-05-12 00:22:48 +08:00
shuaiplus 6b671450a8 feat: update version to 1.5.2 in package.json, package-lock.json, and app-version.ts 2026-05-12 00:11:08 +08:00
shuaiplus c0df6d1c16 feat: update styling for sensitive actions module to enhance UI consistency 2026-05-11 23:49:47 +08:00
shuaiplus 35f9512d94 feat: enhance admin invites UI and improve styling for better usability 2026-05-11 20:22:47 +08:00
shuaiplus 9e39161fc7 Add new payment logo SVGs for Discover, JCB, Maestro, Mastercard, UnionPay, and Visa
- Added discover.svg for Discover card logo.
- Added jcb.svg for JCB card logo.
- Added maestro.svg for Maestro card logo.
- Added mastercard.svg for Mastercard logo.
- Added unionpay.svg for UnionPay logo.
- Added visa.svg for Visa card logo.
2026-05-10 23:33:41 +08:00
shuaiplus 7c58282e42 feat: add registration invite code handling and improve error translations
- Updated AuthViews component to conditionally show invite code field based on registrationInviteRequired prop.
- Enhanced error handling in auth API functions to use translateServerError for better user feedback.
- Added new translations for various server error messages in English, Spanish, Russian, Chinese (Simplified and Traditional).
- Modified demo initial bootstrap state to include registrationInviteRequired flag.
- Updated types to include registrationInviteRequired in WebBootstrapResponse.
2026-05-10 23:07:07 +08:00
shuaiplus e0d81f2733 feat: reset password visibility and history state on cipher selection change 2026-05-10 19:57:32 +08:00
shuaiplus 1d23b3fe5e feat: add wiki directory to .gitignore for better file management 2026-05-10 19:02:56 +08:00
shuaiplus a0d4d7a1ff feat: update custom field input to textarea for better usability; enhance styles for improved display 2026-05-10 18:17:09 +08:00
shuaiplus 2f1b61e883 feat: update backup recommendations UI; enhance styles and structure for better user experience 2026-05-10 01:54:12 +08:00
shuaiplus 4e62c90700 feat: enhance website icon loading logic; implement error handling and timeout management 2026-05-09 23:46:33 +08:00
shuaiplus 7afb496eb0 feat: enhance website icon loading mechanism; implement icon loading state management and error handling 2026-05-09 23:00:56 +08:00
shuaiplus 5809e3eebc feat: remove drag-and-drop functionality for TOTP and website rows; update styles and translations for improved user experience 2026-05-08 16:09:02 +08:00
shuaiplus 2e9bbe6801 feat: add resourcePriorityPlugin for enhanced resource loading; update chunking strategy in Vite config 2026-05-08 01:20:00 +08:00
shuaiplus dc0eec7c54 feat: preload DomainRulesPage component for authenticated workspace and demo experience 2026-05-07 23:26:48 +08:00
shuaiplus a0605299f0 feat: implement navigation layout options and styles in AppAuthenticatedShell component; add translations for navigation layout in multiple languages 2026-05-07 23:20:30 +08:00
shuaiplus db68437a0b feat: add admin pagination styling to AdminPage component 2026-05-07 23:00:40 +08:00
shuaiplus 77d8411ea9 feat: add search index headers and robots.txt generation for SEO control 2026-05-07 22:31:15 +08:00
shuaiplus 0c1ab3db48 feat: update development and deployment scripts to include build step; refactor navigation links in AppAuthenticatedShell component 2026-05-07 22:23:39 +08:00
shuaiplus 6cc6e94b91 feat: update README and README_EN to improve layout and accessibility of links 2026-05-07 20:56:47 +08:00
shuaiplus 37ae493fa7 feat: add contributing guidelines and pull request template; update schema comments and documentation 2026-05-07 20:29:39 +08:00
shuaiplus 33f7c5d88a feat: update schema synchronization note and add device fields to refresh_tokens and devices tables 2026-05-07 19:49:38 +08:00
shuaiplus c6c8979772 feat: include domain settings count validation in backup import functions 2026-05-07 19:43:06 +08:00
shuaiplus a00279f47d feat: add domain settings support in backup import and export processes 2026-05-07 19:36:32 +08:00
shuaiplus 669d7ef242 feat: add function to export portable backup settings envelope 2026-05-07 19:23:22 +08:00
shuaiplus 97d2117e15 feat: enhance TOTP configuration parsing with algorithm, digits, and period options 2026-05-06 22:23:26 +08:00
shuaiplus 429b747710 feat: add mobile detail sheet styles and improve text overflow handling in vault 2026-05-06 22:11:14 +08:00
shuaiplus a06853835d feat: improve JSON formatting for global domains and custom domains 2026-05-06 01:20:20 +08:00
shuaiplus c4ff063865 feat: format rules JSON output for better readability 2026-05-06 01:11:57 +08:00
shuaiplus 70b0a3a394 feat: add NodeWarden-compat to .gitignore 2026-05-06 00:50:45 +08:00
shuaiplus e7c07fda4e feat: enhance navigation with collapsible groups and improve styles 2026-05-06 00:47:18 +08:00
shuaiplus 0a001bebcc feat: add domain rules management feature
- Introduced a new DomainRulesPage component for managing custom and global equivalent domains.
- Updated AppMainRoutes to include a route for domain rules.
- Added API functions to fetch and save domain rules.
- Enhanced localization with new strings for domain rules in multiple languages.
- Updated styles for the new domain rules interface and ensured responsiveness.
- Added types for domain rules in the TypeScript definitions.
2026-05-06 00:33:09 +08:00
shuaiplus 246c73a3d3 Update version number to 1.5.1 2026-05-04 22:05:00 +08:00
shuaiplus 3d95c959f7 Added the preload demo experience feature to support presentation mode 2026-05-04 21:44:10 +08:00
shuaiplus e0737006c2 Optimize the public sending page and navigation logic in presentation mode to ensure consistency in user experience 2026-05-04 21:35:21 +08:00
shuaiplus 70dc9a76a9 Add isolated Pages demo mode with sample vault data 2026-05-04 21:09:10 +08:00
shuaiplus ba38b77387 Update UI translations 2026-05-04 04:20:41 +08:00
shuaiplus 1b4d263d6e Polish vault icons and mobile layout 2026-05-04 04:20:23 +08:00
shuaiplus 97a3aa691d Improve management page loading states 2026-05-04 04:19:59 +08:00
shuaiplus 0ab7c44981 Polish public Send pages 2026-05-04 04:19:17 +08:00
shuaiplus 75a6a593dc Improve app startup and route fallbacks 2026-05-04 04:19:02 +08:00
shuaiplus 45f0387526 feat: add TOTP QR code scanning functionality and related UI components 2026-05-04 01:44:27 +08:00
shuaiplus 851c9c4080 fix: update version display to be a link to the latest release 2026-05-01 05:34:05 +08:00
shuaiplus a73f9a6d87 chore: update version to 1.5.0 in package.json, package-lock.json, and app-version.ts 2026-05-01 05:30:44 +08:00
shuaiplus 77a9faac88 fix(i18n): update password updated value translation in Simplified and Traditional Chinese locales 2026-05-01 02:04:10 +08:00
shuaiplus 0c00114cc8 Update localization files for backup destinations and API client credentials
- Changed references from E3 to S3 in Russian, Simplified Chinese, and Traditional Chinese localization files.
- Updated the corresponding keys and descriptions to reflect the change in backup destination protocols.
- Improved the Vite configuration to dynamically match locale files, simplifying the code for locale handling.
2026-04-30 15:03:05 +08:00
shuaiplus 9c5fbda374 feat: refactor vault component helpers to use dedicated functions for options retrieval 2026-04-29 15:28:23 +08:00
shuaiplus 85147e1569 Refactor code structure for improved readability and maintainability 2026-04-29 03:23:04 +08:00
shuaiplus 29a846c562 feat(i18n): initialize internationalization and update Vite config for locale handling
- Added `initI18n` function call in `main.tsx` to bootstrap internationalization before rendering the app.
- Updated Vite configuration to handle specific locale files for English and Chinese.
2026-04-29 02:49:45 +08:00
shuaiplus 3c5f43ecc2 feat: refactor website icon handling by moving utility functions to a dedicated module 2026-04-29 00:20:17 +08:00
shuaiplus 68ded534a4 feat: enhance backup process with lease management and attachment deletion
- Implemented a backup runner lease mechanism to prevent concurrent backup executions.
- Added `deleteAllAttachmentsForCiphers` function to delete attachments for multiple ciphers efficiently.
- Introduced `bulkDeleteAttachmentsByIds` method in storage to handle batch deletion of attachments.
- Updated backup execution logic to utilize the new lease management and ensure timely updates during the backup process.
- Refactored cipher deletion to handle attachments more effectively.
- Improved website icon loading with a dedicated caching mechanism for better performance.
- Added new index on `ciphers` table for `folder_id` to optimize queries related to folder management.
- Enhanced response handling for CORS policy to allow credentials for specific origins.
2026-04-28 23:40:43 +08:00
shuaiplus 69b98f9e67 refactor: Remove unused APIs and data structures, optimize loading state component styles 2026-04-28 23:01:23 +08:00
shuaiplus 1b0386bf78 feat: implement vault synchronization and decryption improvements
- Added background synchronization for vault core data, including optional folder updates.
- Introduced a new API endpoint to retrieve the vault revision date.
- Enhanced vault synchronization logic to utilize a caching mechanism for improved performance.
- Created a new vault cache module to handle IndexedDB storage for vault core snapshots.
- Implemented a worker for asynchronous decryption of vault data, improving UI responsiveness.
- Updated main application settings to adjust query stale time for better data freshness.
- Refactored vault-related API functions to support cache keys for more efficient data retrieval.
2026-04-28 22:10:34 +08:00
shuaiplus aa6f9210b4 feat: implement cipher decryption functionality and update related API methods 2026-04-28 00:34:52 +08:00
shuaiplus 3be6a16d90 refactor: clean up vault components by removing unused drag-and-drop functionality and optimizing icon loading logic 2026-04-27 23:37:35 +08:00
shuaiplus fdb4cb91bf feat: implement caching for cryptographic keys to improve performance and reduce overhead 2026-04-27 22:49:52 +08:00
shuaiplus 4b69f71ddb refactor: optimize TOTP and vault components with useMemo for performance improvements 2026-04-27 15:14:32 +08:00
qaz741wsd856 44020541e8 refactor: make notifyUserVaultSync and notifyUserLogout functions non-blocking by using waitUntil 2026-04-27 14:53:27 +08:00
shuaiplus 5869755c74 feat: update favicon and logo images to improve visual quality 2026-04-27 02:29:08 +08:00
shuaiplus 5b62d2142e fix: correct typo in README description 2026-04-27 02:25:53 +08:00
shuaiplus 575cf7ca79 feat: add TOTP secret input actions and enhance dark mode styles 2026-04-27 02:15:41 +08:00
shuaiplus bfd347a52c feat: update SVG logos and enhance brand wordmark styling 2026-04-27 02:01:27 +08:00
shuaiplus 7ab836d0f3 feat: enhance sync functionality by adding excludeSends option and refactor related API calls 2026-04-27 01:41:56 +08:00
shuaiplus d589b15123 feat: replace PNG logos with SVG for better scalability and update styles for improved responsiveness 2026-04-27 00:57:45 +08:00
shuaiplus f48f3d0c8e feat: implement drag-and-drop reordering for vault items and enhance sorting functionality 2026-04-26 20:32:55 +08:00
shuaiplus 2f7e66ee69 feat: enhance mobile layout with FAB visibility and responsive adjustments 2026-04-26 19:59:50 +08:00
shuaiplus 0cffbcd1f8 feat: update .gitignore to include settings.json 2026-04-26 19:40:06 +08:00
shuaiplus 64b4da4035 feat: add folder creation date and sorting functionality in Vault components 2026-04-26 19:28:49 +08:00
shuaiplus 3d2285e7af feat: refine styles for improved UI consistency and responsiveness across themes 2026-04-26 00:03:45 +08:00
shuaiplus 62f0aedc27 feat: enhance OnePassword CSV parsing with improved field handling and new category type support 2026-04-25 23:45:22 +08:00
shuaiplus 193e0ca189 feat: enhance cipher import process by preserving source ID during payload construction 2026-04-25 19:01:51 +08:00
shuaiplus 4a63c077f5 feat: enhance URI handling and TOTP field extraction in import functions 2026-04-25 16:35:35 +08:00
shuaiplus 15ee922777 chore: update version to 1.4.6 in package.json, package-lock.json, and app-version.ts 2026-04-25 16:05:07 +08:00
shuaiplus 2ea0b2c14c feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments 2026-04-25 15:52:00 +08:00
shuaiplus 4ec1926888 fix: correct dialog-card width from 5000px to 500px for proper layout 2026-04-25 12:07:45 +08:00
shuaiplus 3995e01336 feat: enhance icon error handling and loading state management in TotpCodesPage and VaultListIcon components 2026-04-25 10:20:30 +08:00
shuaiplus 481536ba24 feat: update list icon opacity and z-index for improved loading behavior 2026-04-25 04:40:22 +08:00
shuaiplus db8b9263a1 feat: implement session timeout feature with customizable actions and update UI components 2026-04-25 03:49:15 +08:00
shuaiplus a1f7250e90 feat: update mobile layout query to 1180px and enhance icon loading experience 2026-04-25 03:19:06 +08:00
shuaiplus e4bc1b9bbe Refactor frontend styles toward Tailwind utilities and unified design system 2026-04-25 02:23:10 +08:00
shuaiplus 514889adfc feat: refactor TOTP code handling to improve state management and refresh logic 2026-04-25 01:48:20 +08:00
shuaiplus fccc85c4bb feat: enhance ConfirmDialog with focus management and accessibility improvements 2026-04-25 01:36:12 +08:00
shuaiplus acd59a7387 feat: add auto-lock feature with customizable timeout settings and update UI for security preferences 2026-04-24 15:27:46 +08:00
shuaiplus d40b0514fd Refactor styles to utilize Tailwind CSS utility classes for improved consistency and maintainability across forms, motion, shell, and vault components. Remove deprecated reduced-motion styles and consolidate motion-related animations. Update color tokens for better contrast and accessibility. Introduce a new Tailwind CSS configuration file. 2026-04-24 15:14:12 +08:00
shuaiplus 033d44808f chore: update version to 1.4.5 in package.json, package-lock.json, and app-version.ts 2026-04-24 00:51:27 +08:00
shuaiplus 4246e179f1 Merge branch 'pr-200' 2026-04-23 23:22:01 +08:00
shuaiplus fe8d9e0b7d fix: harden API key authentication 2026-04-23 23:17:25 +08:00
maooyer 1147c1e013 feat(web): Add api key components 2026-04-23 23:17:25 +08:00
maooyer 31ffd98166 feat(server): Add api key handler 2026-04-23 23:17:25 +08:00
maooyer 7d7562d191 feat(server): Add api_key in backup repo 2026-04-23 23:17:25 +08:00
maooyer d6e5a1c40b feat(server): Add the field api_key at the database 2026-04-23 23:17:25 +08:00
shuaiplus 77794e43ce feat: remove unused styles for select input in dark theme 2026-04-22 23:50:25 +08:00
shuaiplus b990f17a3e Add new styles for app shell, tokens, and vault components
- Introduced `shell.css` for the main application layout, including styles for the app shell, top bar, and user interactions.
- Created `tokens.css` to define CSS variables for theming, including colors, shadows, and transition durations for light and dark modes.
- Developed `vault.css` for the vault component, implementing grid layouts, sidebar styles, search inputs, and list item designs.
2026-04-22 23:44:51 +08:00
shuaiplus 31b8ec6f7d feat: update VaultListPanel styles for improved item display and adjust row height for better layout 2026-04-22 21:39:15 +08:00
shuaiplus ef47597be5 feat: update website branding with new logo and wordmark, enhance styles for better responsiveness 2026-04-18 21:44:27 +08:00
shuaiplus 408874ac05 feat: update version to 1.4.4 in package.json, package-lock.json, and app-version.ts 2026-04-18 04:02:49 +08:00
shuaiplus dabd2c923e feat: optimize attachment handling in backup process 2026-04-18 03:55:27 +08:00
shuaiplus 08414d7cf2 feat: add support for new cipher properties and enhance import functionality 2026-04-18 03:44:17 +08:00
shuaiplus 38b33df719 feat: add password history feature with dialog and encryption handling 2026-04-18 02:05:01 +08:00
shuaiplus 7ebd12fa07 feat: add device note and last seen tracking to devices, enhance device management features 2026-04-18 01:43:21 +08:00
entsalze f7cbdaf730 feat: update NodeWarden logo image 2026-04-17 15:19:54 +08:00
shuaiplus 6cae5cb218 feat: update version to 1.4.3 in package.json, package-lock.json, and app-version.ts 2026-04-16 23:01:20 +08:00
shuaiplus d96ad9bb1c Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-16 22:30:01 +08:00
shuaiplus 92d1f07998 feat: enhance cipher handling with nested object merging and additional fields 2026-04-16 22:29:55 +08:00
maooyer a8432ab94b feat: add issue templates 2026-04-13 00:31:21 +08:00
shuaiplus 2230f75d8a feat: add loading state management for TOTP and import/export operations 2026-04-09 23:27:40 +08:00
shuaiplus a982a5a57b feat: enhance database indexing and optimize sync response handling 2026-04-09 23:05:00 +08:00
github-actions[bot] 4d7ee2164a chore: restore sync-upstream workflow after sync 2026-04-09 17:23:42 +08:00
shuaiplus 34d4851981 feat: add links to documentation homepage and quick start in README 2026-04-09 17:05:52 +08:00
shuaiplus 4827a4958e Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-09 16:50:49 +08:00
shuaiplus 70463d3fc7 feat: add Telegram channel and group links to README files 2026-04-09 16:50:43 +08:00
shuaiplus 681705ee13 feat: add passkey deletion functionality and related UI components 2026-04-08 14:47:53 +08:00
shuaiplus 5bf7c79ada feat: add FIDO2 credentials support in cipher handling and UI components 2026-04-08 14:40:49 +08:00
shuaiplus c516194d54 feat: implement web session handling and enhance token management 2026-04-07 22:14:26 +08:00
shuaiplus 53231a4878 feat: enhance backup progress handling and improve user status toggling 2026-04-07 20:58:23 +08:00
shuaiplus c9e7417825 feat: add timezone support for backup file naming and extraction 2026-04-07 20:24:28 +08:00
shuaiplus 76623d7201 Refactor: Remove passkey-related functionality and types
- Deleted passkey-related interfaces and types from index.ts and types.ts.
- Removed passkey handling from App component, including related state and functions.
- Cleaned up API calls in auth.ts, removing passkey registration and login functions.
- Updated vault and import formats to eliminate passkey references.
- Removed passkey support checks and UI elements from AuthViews and SettingsPage.
- Cleaned up unused passkey helper functions and constants.
- Adjusted related components and hooks to ensure consistent functionality without passkey support.
2026-04-06 00:46:13 +08:00
shuaiplus 90a7731351 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-01 23:05:47 +08:00
shuaiplus f4adeb8ec9 fix: enhance QR code visibility with background and border adjustments 2026-04-01 23:05:44 +08:00
saleacy bb0b82f838 Update folderId assignment to include c.folderId
修复导入数据时选择指定文件夹未生效的BUG。
2026-04-01 22:54:56 +08:00
qaz741wsd856 be82c953d6 feat: add request URL normalization 2026-03-31 10:42:57 +08:00
Shuai edd2ba2e44 refine passkey settings list, rename and delete UX 2026-03-31 01:24:12 +08:00
Shuai 0f6da7d147 feat: add passkey-first login and management flow 2026-03-31 01:24:12 +08:00
Shuai 1184cb8d9a feat(vault): add folder rename action in sidebar 2026-03-31 00:29:34 +08:00
shuaiplus 882fa2e8c8 chore: ignore local wiki repo 2026-03-29 01:07:28 +08:00
shuaiplus b6b7e46f79 feat: Update README 2026-03-29 01:00:33 +08:00
shuaiplus 144d3d9406 feat: add decodeIncomingMessage function and improve webSocketMessage handling 2026-03-28 15:28:46 +08:00
qaz741wsd856 10707cf902 refactor: refactor NotificationsHub to use hibernation api
- Updated NotificationsHub class to extend DurableObject.
- Persisted connection state into attachment instead of memory.
- Removed unnecessary ping functions & server-side periodic ping logic and added auto response which integrated into the WebSocket lifecycle.
- Added echo for binary ws messages (for keeplive of MessagePack).
- Added ping timer functionality in the App component to manage WebSocket connections more effectively.
2026-03-28 14:56:40 +08:00
shuaiplus 3bd4f6a9fe feat: enhance error handling for remote attachment index loading 2026-03-28 14:51:58 +08:00
shuaiplus 3d4e95ef66 feat: update version to 1.4.2 across package files 2026-03-28 06:01:07 +08:00
shuaiplus 2a7879efaa feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes.
- Introduced progress dispatching for backup export and restore operations.
- Implemented new API endpoints for inspecting remote backup integrity.
- Enhanced user interface with progress indicators and warning dialogs for integrity issues.
- Updated localization strings for new features and user feedback.
- Refactored backup-related functions for better clarity and maintainability.
2026-03-28 05:52:47 +08:00
shuaiplus bd8e26d2ab feat: add search clear functionality and improve search input styling 2026-03-28 01:58:47 +08:00
shuaiplus 783fcbbe4b feat: add normalization functions for optional IDs and public keys in cipher and user decryption handling 2026-03-28 01:18:40 +08:00
shuaiplus 9e892e85a2 feat: update version to 1.4.1 and enhance drag-and-drop functionality for TOTP and website entries 2026-03-27 00:54:24 +08:00
shuaiplus 3e5a80e498 Refactor code structure for improved readability and maintainability 2026-03-27 00:08:29 +08:00
shuaiplus 89308fc8a6 feat: enhance login URI handling with match options and improve UI components 2026-03-26 21:59:50 +08:00
shuaiplus fe0bd80f43 feat: improve handling of archived timestamps in cipher storage normalization 2026-03-24 00:56:56 +08:00
shuaiplus 0062fd6c48 feat: enhance dark theme styles for mobile settings and table elements 2026-03-23 09:06:36 +08:00
shuaiplus 7373eeb501 feat: add backup start time configuration and theme switch functionality
- Introduced BACKUP_DEFAULT_START_TIME constant for backup scheduling.
- Updated BackupScheduleConfig interface to include startTime.
- Implemented normalizeStartTime function for validating and normalizing start time input.
- Enhanced backup settings parsing to accommodate start time.
- Added start time input field in BackupDestinationDetail component.
- Created ThemeSwitch component for toggling between light and dark themes.
- Integrated theme preference management in App component.
- Updated styles for dark mode support across the application.
- Added translations for theme toggle and backup start time labels.
2026-03-23 08:53:18 +08:00
shuaiplus 8b07cd4409 feat: refactor unarchive handling to support bulk unarchive and update prop types 2026-03-23 08:40:40 +08:00
shuaiplus 0fc7bd7985 feat: implement unarchive functionality for selected ciphers with state management 2026-03-23 08:32:43 +08:00
shuaiplus 58c029beba feat: add .tmp/ directory to .gitignore 2026-03-23 08:28:15 +08:00
shuaiplus ac79cbd8bd feat: remove temporary subproject references for bitwarden components 2026-03-23 08:28:07 +08:00
shuaiplus 96fc3ae485 feat: implement archive and bulk archive functionality with confirmation dialogs 2026-03-23 08:22:08 +08:00
shuaiplus cb4632cd04 feat: add bulk unarchive functionality for ciphers 2026-03-23 08:18:15 +08:00
shuaiplus f7b5534cd0 feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers.
- Implemented bulk archiving and unarchiving of ciphers in the vault.
- Updated the storage schema to include `archived_at` timestamps for ciphers.
- Enhanced user interface to support archiving actions in the vault.
- Added necessary translations for archive-related actions.
- Updated user and device models to accommodate new fields related to archiving.
2026-03-23 01:10:48 +08:00
shuaiplus b50673f7d9 feat: update README files to clarify cloud backup center and password hint features 2026-03-20 06:55:20 +08:00
shuaiplus 98e94e766f feat: update README files for clarity and consistency in descriptions 2026-03-20 06:47:25 +08:00
shuaiplus a17ed646a0 feat: update backup routes and navigation links for consistency 2026-03-20 05:53:24 +08:00
shuaiplus c2b920532d feat: refactor backup scheduling to use interval hours and update UI components 2026-03-20 05:44:00 +08:00
shuaiplus fba2aa9746 feat: update version to 1.4.0 and integrate APP_VERSION in components 2026-03-20 05:03:04 +08:00
shuaiplus cbf1e86881 feat: enhance backup functionality with attachment options
- Added support for including attachments in backup exports.
- Updated backup-related interfaces and functions to handle attachment options.
- Introduced a new UI component for selecting attachment inclusion during backup operations.
- Modified existing components to integrate the new attachment functionality.
- Improved user feedback and error handling during backup processes.
2026-03-20 04:55:23 +08:00
shuaiplus 3d38424d77 feat: optimize backup archive settings for improved performance and reliability 2026-03-19 01:13:19 +08:00
shuaiplus 5ff322d809 feat: simplify asset serving and enhance bootstrap response handling 2026-03-19 00:52:58 +08:00
shuaiplus facd0ea5f7 feat: add master password hint functionality
- Updated user model to include masterPasswordHint.
- Modified sync handler to return masterPasswordHint.
- Implemented password hint retrieval in public API.
- Enhanced user profile management to allow updating of password hint.
- Added UI components for displaying and editing password hint.
- Updated localization files for new password hint strings.
- Improved rate limiting for sensitive public requests.
- Adjusted database schema to accommodate master password hint.
2026-03-19 00:38:56 +08:00
shuaiplus 8bc43b8f0c feat: update icon fetching logic to support multiple upstream sources 2026-03-18 22:37:37 +08:00
shuaiplus bb3fe41330 feat: implement direct file upload for sends with JWT token validation
- Added `processSendFileUpload` function to handle file uploads for sends.
- Integrated JWT token creation and verification for secure file uploads.
- Updated `handleCreateFileSendV2` and `handleGetSendFileUpload` to use new upload URL generation.
- Refactored upload handling in `handleUploadSendFile` and `handlePublicUploadSendFile` to utilize the new upload process.
- Introduced `uploadDirectEncryptedPayload` for handling direct uploads with progress tracking.
- Enhanced API routes to support both POST and PUT methods for attachment uploads.
- Added localization strings for upload progress messages.
- Created utility functions for direct upload URL building and payload parsing.
2026-03-18 02:26:10 +08:00
shuaiplus 3204eeb9ab feat: add duplicate handling features and UI elements for cipher management 2026-03-18 01:39:35 +08:00
shuaiplus 9280f6916e feat: add item limit for ciphers import and streamline response handling 2026-03-18 00:56:32 +08:00
shuaiplus 3f7ca52983 feat: refactor authentication flow and improve token verification process 2026-03-18 00:24:45 +08:00
shuaiplus 011fe15aae feat: enhance sync cache with size limits and entry management 2026-03-18 00:12:18 +08:00
shuaiplus 98a653efb6 feat: add support for steam:// secrets in TOTP handling and corresponding tests 2026-03-17 23:35:34 +08:00
copilot-swe-agent[bot] b5d58f1aa8 fix: support steam totp code generation and formatting
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-17 11:59:30 +08:00
shuaiplus 010cda972c feat: add observability configuration with logging and tracing options 2026-03-17 09:17:52 +08:00
shuaiplus 911cec337e feat: remove unused deriveLoginHash import and use deriveLoginHashLocally instead 2026-03-17 09:09:03 +08:00
shuaiplus 40fe9223ac feat: add parseSerializedUris function and update Bitwarden CSV parsing to handle multiple URIs 2026-03-17 09:03:14 +08:00
shuaiplus 3791f89a5c feat: enhance login handling by introducing local hash derivation and updating session management 2026-03-17 08:50:47 +08:00
shuaiplus 0ba85229a9 feat: refactor setup handling and enhance asset serving with bootstrap integration 2026-03-16 23:48:08 +08:00
shuaiplus b5f8ef28cc feat: enhance cipher data handling by expanding CipherRow interface and updating database queries 2026-03-16 22:41:47 +08:00
shuaiplus c16f9881d3 feat: add User-Agent header to fetch request in handleWebsiteIcon function 2026-03-16 22:08:08 +08:00
copilot-swe-agent[bot] 99f5bc735e fix: restore User-Agent header in website icon proxy to fix favicon display
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-16 17:43:00 +08:00
shuaiplus 623ad1acda feat: optimize XML decoding by using a switch statement for entity replacements 2026-03-16 00:58:13 +08:00
shuaiplus 43ec591414 feat: optimize XML decoding by using a switch statement for entity replacements 2026-03-16 00:58:13 +08:00
shuaiplus 2ebd0b60f7 feat: optimize path trimming and clean up unused imports in VaultPage component 2026-03-16 00:50:59 +08:00
shuaiplus 4de8643360 feat: optimize path trimming and clean up unused imports in VaultPage component 2026-03-16 00:50:59 +08:00
shuaiplus 2f448964f2 feat: enhance backup import functionality to handle skipped items and provide detailed feedback 2026-03-16 00:38:44 +08:00
shuaiplus 9fcd700dc4 feat: enhance backup import functionality to handle skipped items and provide detailed feedback 2026-03-16 00:38:44 +08:00
shuaiplus 3cb2ef1015 feat: enhance backup archive processing with configurable chunk sizes and compression levels 2026-03-16 00:24:14 +08:00
shuaiplus 557f4bfbbd feat: enhance backup archive processing with configurable chunk sizes and compression levels 2026-03-16 00:24:14 +08:00
shuaiplus c42a52f889 feat: enhance backup archive functionality with blob task management and concurrency handling 2026-03-16 00:05:11 +08:00
shuaiplus 3d33f78a0c feat: enhance backup archive functionality with blob task management and concurrency handling 2026-03-16 00:05:11 +08:00
shuaiplus 4b8cad6d00 feat: enhance backup and download functionalities
- Updated `BackupCenterPage` to support download progress tracking during remote backup downloads.
- Modified `ImportPage` to simplify export functionality by removing unnecessary payload handling.
- Improved `JwtWarningPage` to utilize a new clipboard utility for copying text with feedback.
- Enhanced `PublicSendPage` to show download progress for files being downloaded.
- Updated `RecoverTwoFactorPage` to include autocomplete attributes for better user experience.
- Refactored `SendsPage` to use the new clipboard utility for copying access URLs.
- Enhanced `SettingsPage` to utilize the clipboard utility for copying sensitive information.
- Improved `TotpCodesPage` to use the clipboard utility for copying TOTP codes.
- Updated `VaultPage` and related components to support download progress for attachments.
- Introduced a new `app-notify` module for consistent notification handling across the application.
- Created a `clipboard` utility for improved clipboard interactions with user feedback.
- Added progress tracking for file downloads in the API layer, enhancing user experience during downloads.
2026-03-15 23:12:45 +08:00
shuaiplus fc2667501c feat: enhance backup and download functionalities
- Updated `BackupCenterPage` to support download progress tracking during remote backup downloads.
- Modified `ImportPage` to simplify export functionality by removing unnecessary payload handling.
- Improved `JwtWarningPage` to utilize a new clipboard utility for copying text with feedback.
- Enhanced `PublicSendPage` to show download progress for files being downloaded.
- Updated `RecoverTwoFactorPage` to include autocomplete attributes for better user experience.
- Refactored `SendsPage` to use the new clipboard utility for copying access URLs.
- Enhanced `SettingsPage` to utilize the clipboard utility for copying sensitive information.
- Improved `TotpCodesPage` to use the clipboard utility for copying TOTP codes.
- Updated `VaultPage` and related components to support download progress for attachments.
- Introduced a new `app-notify` module for consistent notification handling across the application.
- Created a `clipboard` utility for improved clipboard interactions with user feedback.
- Added progress tracking for file downloads in the API layer, enhancing user experience during downloads.
2026-03-15 23:12:45 +08:00
shuaiplus 9820c2ed44 feat: implement pending authentication actions for login, registration, and unlock flows 2026-03-15 18:32:30 +08:00
shuaiplus a4b45c1b59 feat: implement pending authentication actions for login, registration, and unlock flows 2026-03-15 18:32:30 +08:00
shuaiplus 171f3c5d71 feat: refactor authentication forms to use <form> elements for better submission handling 2026-03-15 18:26:36 +08:00
shuaiplus 588408ff96 feat: refactor authentication forms to use <form> elements for better submission handling 2026-03-15 18:26:36 +08:00
shuaiplus 722d3db0e9 refactor: enhance manual chunking in Vite config for better code splitting 2026-03-15 18:15:28 +08:00
shuaiplus ca74e55979 refactor: enhance manual chunking in Vite config for better code splitting 2026-03-15 18:15:28 +08:00
shuaiplus f0ace28bf2 feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing.
- Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations.
- Implemented encryption and decryption methods for secure data handling within the vault.
- Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
2026-03-15 04:17:09 +08:00
shuaiplus 1cef45e373 feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing.
- Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations.
- Implemented encryption and decryption methods for secure data handling within the vault.
- Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
2026-03-15 04:17:09 +08:00
shuaiplus 1fcfeb91d1 feat: refactor import routes and enhance backup state management with user ID 2026-03-15 03:44:38 +08:00
shuaiplus f749bbf7fd feat: refactor import routes and enhance backup state management with user ID 2026-03-15 03:44:38 +08:00
shuaiplus 5faf1bdee1 feat: update backup strategy terminology for clarity in UI 2026-03-15 03:36:28 +08:00
shuaiplus 8755b64f56 feat: update backup strategy terminology for clarity in UI 2026-03-15 03:36:28 +08:00
shuaiplus b1c6ec50da feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers.
- Updated i18n translations for backup strategy to reflect new terminology and improved descriptions.
- Enhanced types with optional private and public keys in user profiles.
- Redesigned backup-related styles for better layout and responsiveness.
- Updated TypeScript configuration to include shared modules.
- Configured Vite to resolve shared modules and allow filesystem access.
- Added cron triggers for periodic tasks in Wrangler configuration.
2026-03-15 03:34:16 +08:00
shuaiplus 05f1b2f9a8 feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers.
- Updated i18n translations for backup strategy to reflect new terminology and improved descriptions.
- Enhanced types with optional private and public keys in user profiles.
- Redesigned backup-related styles for better layout and responsiveness.
- Updated TypeScript configuration to include shared modules.
- Configured Vite to resolve shared modules and allow filesystem access.
- Added cron triggers for periodic tasks in Wrangler configuration.
2026-03-15 03:34:16 +08:00
shuaiplus 51d0e60cf1 refactor: improve base32 normalization function for better readability and performance 2026-03-12 02:28:19 +08:00
shuaiplus 33323439cd refactor: improve base32 normalization function for better readability and performance 2026-03-12 02:28:19 +08:00
shuaiplus cc522ec40f fix: clean up security scan warnings 2026-03-12 02:18:14 +08:00
shuaiplus 96b076b113 fix: clean up security scan warnings 2026-03-12 02:18:14 +08:00
shuaiplus 246a743822 merge: adopt simplified security scan workflow from pr-70 2026-03-12 02:01:27 +08:00
shuaiplus 73e90f7860 merge: adopt simplified security scan workflow from pr-70 2026-03-12 02:01:27 +08:00
shuaiplus 37cbb2f2c7 refactor: simplify security scan reporting workflow 2026-03-12 02:01:22 +08:00
shuaiplus b10e6032d4 refactor: simplify security scan reporting workflow 2026-03-12 02:01:22 +08:00
shuaiplus 0bb1baf768 refactor: optimize random byte generation for recovery and JWT secret functions 2026-03-12 01:59:28 +08:00
shuaiplus a994214e4a refactor: optimize random byte generation for recovery and JWT secret functions 2026-03-12 01:59:28 +08:00
shuaiplus 3eb517a92f feat(ciphers): add bulk restore and permanent delete functionality for ciphers
style: enhance list count display in VaultPage and styles
fix(i18n): add translations for bulk restore and permanent delete messages
2026-03-12 01:37:33 +08:00
shuaiplus f51468b7b9 feat(ciphers): add bulk restore and permanent delete functionality for ciphers
style: enhance list count display in VaultPage and styles
fix(i18n): add translations for bulk restore and permanent delete messages
2026-03-12 01:37:33 +08:00
shuaiplus ad764a9c5b refactor(cors): simplify origin handling and improve CORS headers 2026-03-11 02:36:50 +08:00
shuaiplus 94cb6177f2 refactor(cors): simplify origin handling and improve CORS headers 2026-03-11 02:36:50 +08:00
shuaiplus 9b26feb310 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-11 02:22:45 +08:00
shuaiplus 80d6315148 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-11 02:22:45 +08:00
shuaiplus f4d2e7932a Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance
- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
2026-03-11 02:22:35 +08:00
shuaiplus 7c64453c1a Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance
- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
2026-03-11 02:22:35 +08:00
nap0o 810edfe8a6 feat: 利用Github Action进行代码安全扫描,并生成报告 2026-03-10 11:34:42 +08:00
nap0o d1aee25905 feat: 利用Github Action进行代码安全扫描,并生成报告 2026-03-10 11:34:42 +08:00
Shuai 3b0ccf2a77 Update wrangler.toml 2026-03-09 09:48:07 +08:00
Shuai cf815805e9 Update wrangler.toml 2026-03-09 09:48:07 +08:00
shuaiplus bc5efbf2fd feat(notifications): enhance NotificationsHub with device status updates and logout notifications 2026-03-09 01:21:39 +08:00
shuaiplus 616d6273bb feat(notifications): enhance NotificationsHub with device status updates and logout notifications 2026-03-09 01:21:39 +08:00
shuaiplus 1285f6296e feat(cors): add Access-Control-Allow-Credentials header for CORS support 2026-03-09 00:52:24 +08:00
shuaiplus cb137fe0c7 feat(cors): add Access-Control-Allow-Credentials header for CORS support 2026-03-09 00:52:24 +08:00
shuaiplus 899f1004a3 feat: implement NotificationsHub for real-time vault sync notifications
- Added NotificationsHub durable object to handle WebSocket connections for vault sync notifications.
- Integrated SignalR protocol for message framing and communication.
- Updated storage service methods to return revision date and user ID for vault sync notifications.
- Enhanced existing handlers (attachments, ciphers, folders, sends, and import) to notify users of vault sync events.
- Created new notifications handler for WebSocket negotiation and binding user IDs.
- Updated frontend to establish WebSocket connection for receiving vault sync notifications.
- Improved CORS headers to support new notification endpoints.
- Bumped wrangler version in package.json to 4.71.0.
2026-03-09 00:25:34 +08:00
shuaiplus f0c57a7f9c feat: implement NotificationsHub for real-time vault sync notifications
- Added NotificationsHub durable object to handle WebSocket connections for vault sync notifications.
- Integrated SignalR protocol for message framing and communication.
- Updated storage service methods to return revision date and user ID for vault sync notifications.
- Enhanced existing handlers (attachments, ciphers, folders, sends, and import) to notify users of vault sync events.
- Created new notifications handler for WebSocket negotiation and binding user IDs.
- Updated frontend to establish WebSocket connection for receiving vault sync notifications.
- Improved CORS headers to support new notification endpoints.
- Bumped wrangler version in package.json to 4.71.0.
2026-03-09 00:25:34 +08:00
shuaiplus 54cf1ff718 feat(i18n): update error messages for device trust operations 2026-03-08 22:24:11 +08:00
shuaiplus e0d53b4683 feat(i18n): update error messages for device trust operations 2026-03-08 22:24:11 +08:00
shuaiplus c34c44ce5b feat(devices): add functionality to delete all authorized devices 2026-03-08 22:12:01 +08:00
shuaiplus d48e6b6ce5 feat(devices): add functionality to delete all authorized devices 2026-03-08 22:12:01 +08:00
shuaiplus 1062725b46 feat: update Content Security Policy to include pwned passwords API 2026-03-08 19:29:06 +08:00
shuaiplus 61dac98a12 feat: update Content Security Policy to include pwned passwords API 2026-03-08 19:29:06 +08:00
shuaiplus c8194a04c7 feat(vault): add password exposure check and related UI enhancements 2026-03-08 19:23:24 +08:00
shuaiplus 219f569969 feat(vault): add password exposure check and related UI enhancements 2026-03-08 19:23:24 +08:00
shuaiplus a372b99fc9 feat: add invite code handling from URL for registration flow 2026-03-08 17:15:37 +08:00
shuaiplus f556782c86 feat: add invite code handling from URL for registration flow 2026-03-08 17:15:37 +08:00
shuaiplus 68583821fe feat: enhance mobile layout and accessibility across components
- Added mobile layout support in AdminPage, SecurityDevicesPage, SendsPage, and VaultPage.
- Implemented responsive design adjustments including mobile sidebar and panel transitions.
- Updated table structures to include data labels for better accessibility.
- Introduced new translations for mobile-specific UI elements.
- Enhanced styles for mobile views, including button adjustments and sidebar behaviors.
2026-03-08 17:07:21 +08:00
shuaiplus ed678a070e feat: enhance mobile layout and accessibility across components
- Added mobile layout support in AdminPage, SecurityDevicesPage, SendsPage, and VaultPage.
- Implemented responsive design adjustments including mobile sidebar and panel transitions.
- Updated table structures to include data labels for better accessibility.
- Introduced new translations for mobile-specific UI elements.
- Enhanced styles for mobile views, including button adjustments and sidebar behaviors.
2026-03-08 17:07:21 +08:00
shuaiplus 0e1152a0b9 feat(vault): add sorting functionality with persistent storage 2026-03-08 14:21:48 +08:00
shuaiplus 5fee320eee feat(vault): add sorting functionality with persistent storage 2026-03-08 14:21:48 +08:00
shuaiplus eeb477b84c feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups.
- Introduced user interface components for backup management in the web app.
- Enhanced import/export logic to handle attachments and provide detailed summaries.
- Updated localization files to include new strings related to backup features.
- Improved styling for backup-related UI elements.
2026-03-08 13:36:51 +08:00
shuaiplus 01f01e5903 feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups.
- Introduced user interface components for backup management in the web app.
- Enhanced import/export logic to handle attachments and provide detailed summaries.
- Updated localization files to include new strings related to backup features.
- Improved styling for backup-related UI elements.
2026-03-08 13:36:51 +08:00
shuaiplus 206b0be566 feat: add TOTP codes page and related components for displaying verification codes 2026-03-08 02:31:36 +08:00
shuaiplus 5c2c6cfb6c feat: add TOTP codes page and related components for displaying verification codes 2026-03-08 02:31:36 +08:00
shuaiplus eec27f3a40 chore: remove obsolete KV ID from wrangler.kv.toml 2026-03-08 01:07:25 +08:00
shuaiplus ec57897a5f chore: remove obsolete KV ID from wrangler.kv.toml 2026-03-08 01:07:25 +08:00
shuaiplus d828f145db docs: update deployment instructions in README and README_EN to reflect new Workers URL 2026-03-07 06:42:12 +08:00
shuaiplus 3f7af954c7 docs: update deployment instructions in README and README_EN to reflect new Workers URL 2026-03-07 06:42:12 +08:00
shuaiplus e7d2c85de9 chore: remove obsolete workflows and update sync process in sync-upstream.yml 2026-03-07 06:36:41 +08:00
shuaiplus 1b242b8404 chore: remove obsolete workflows and update sync process in sync-upstream.yml 2026-03-07 06:36:41 +08:00
shuaiplus 49c71039a4 docs: update README and README_EN with clearer instructions for repository setup and synchronization 2026-03-07 04:01:48 +08:00
shuaiplus 4cec39cfe2 docs: update README and README_EN with clearer instructions for repository setup and synchronization 2026-03-07 04:01:48 +08:00
shuaiplus ca194da822 feat: add workflow to import KV ID from NodeWarden2 and update README for deployment instructions 2026-03-07 03:47:21 +08:00
shuaiplus e931307c8f feat: add workflow to import KV ID from NodeWarden2 and update README for deployment instructions 2026-03-07 03:47:21 +08:00
shuaiplus 23c78b3408 feat: update workflows and README for KV and R2 mode switching 2026-03-07 02:33:29 +08:00
shuaiplus 0fcdc61843 feat: update workflows and README for KV and R2 mode switching 2026-03-07 02:33:29 +08:00
shuaiplus 1aa29dda11 docs: update README and README_EN with clearer deployment instructions and buttons 2026-03-06 03:20:19 +08:00
shuaiplus be572746a3 docs: update README and README_EN with clearer deployment instructions and buttons 2026-03-06 03:20:19 +08:00
shuaiplus bf066fc68b docs: update README with clearer deployment instructions and badges 2026-03-06 03:15:41 +08:00
shuaiplus 40a3105b82 docs: update README with clearer deployment instructions and badges 2026-03-06 03:15:41 +08:00
shuaiplus 03b793b14a feat: refactor kv sync logic to use regex for R2 block replacement 2026-03-06 03:08:38 +08:00
shuaiplus 5f386c80c5 feat: refactor kv sync logic to use regex for R2 block replacement 2026-03-06 03:08:38 +08:00
shuaiplus 54466160af feat: update sync workflow and README for KV storage support 2026-03-06 03:06:34 +08:00
shuaiplus 257928a317 feat: update sync workflow and README for KV storage support 2026-03-06 03:06:34 +08:00
shuaiplus fdf266111b feat: update README files to improve clarity on R2 vs KV storage options 2026-03-06 01:07:24 +08:00
shuaiplus 39ec5da861 feat: update README files to improve clarity on R2 vs KV storage options 2026-03-06 01:07:24 +08:00
shuaiplus 5d636e4977 feat: add support for KV storage mode and enhance attachment handling 2026-03-06 01:00:19 +08:00
shuaiplus 57aa7457ae feat: add support for KV storage mode and enhance attachment handling 2026-03-06 01:00:19 +08:00
shuaiplus 773453b7cc feat: improve client IP identification logic for rate limiting 2026-03-05 22:03:40 +08:00
shuaiplus c54740517c feat: improve client IP identification logic for rate limiting 2026-03-05 22:03:40 +08:00
shuaiplus d054d76afe feat: update Content Security Policy for enhanced security and resource loading 2026-03-05 21:40:39 +08:00
shuaiplus dc7d80ddfc feat: update Content Security Policy for enhanced security and resource loading 2026-03-05 21:40:39 +08:00
shuaiplus dab0961a63 feat: improve error handling and localization for vault operations and import/export processes 2026-03-05 02:55:59 +08:00
shuaiplus 1e34a96c57 feat: improve error handling and localization for vault operations and import/export processes 2026-03-05 02:55:59 +08:00
shuaiplus e12ab2b334 feat: implement constant time comparison for MAC verification to enhance security 2026-03-05 02:41:02 +08:00
shuaiplus 380cd34474 feat: implement constant time comparison for MAC verification to enhance security 2026-03-05 02:41:02 +08:00
shuaiplus 7b5f6163cf feat: remove handleUpdateProfile function to streamline account management 2026-03-05 02:37:27 +08:00
shuaiplus 56235cb94d feat: remove handleUpdateProfile function to streamline account management 2026-03-05 02:37:27 +08:00
shuaiplus 55c5573544 feat: enhance rate limiting with new public request budgets and client IP validation 2026-03-05 02:26:05 +08:00
shuaiplus 49af3e7099 feat: enhance rate limiting with new public request budgets and client IP validation 2026-03-05 02:26:05 +08:00
shuaiplus 9db92d13ab feat: enhance send file download token with JTI for improved validation 2026-03-05 01:31:02 +08:00
shuaiplus c39654ab3c feat: enhance send file download token with JTI for improved validation 2026-03-05 01:31:02 +08:00
shuaiplus 12024203be feat: reorder key assignment logic in handleSetKeys for improved readability 2026-03-05 01:18:23 +08:00
shuaiplus f5684145f9 feat: reorder key assignment logic in handleSetKeys for improved readability 2026-03-05 01:18:23 +08:00
shuaiplus a2654dcde3 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus 8c35d89519 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus cb662b7d70 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 4d5f207ce7 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 1ac063909f feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 3f62a03181 feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 35dc239c25 feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus 7ace10e7cc feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus c99a558b5e feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 8df3221078 feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 819734ce5c feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 36f398b728 feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 7b4733d4c4 feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus 6ca1fa739f feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus af56236dba Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
shuaiplus 7193df7f11 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
Zheng Li 3622c58680 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
Zheng Li 0d36aa9139 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
shuaiplus b5284e669a feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus d63755f67d feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus 4da5525a1a fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 6dcc18e2e9 fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 16a7bcace9 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f230e5c8c2 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f59e81de3a Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 8ac2ab0699 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 227d43194d fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
shuaiplus f9030d5dbb fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
copilot-swe-agent[bot] 3341a9ef74 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
copilot-swe-agent[bot] 41221998c9 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
shuaiplus d0c97ee573 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
shuaiplus fab6d9da67 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
shuaiplus 5dab96f40e feat: add Import & Export page and update Help page with new navigation 2026-03-02 00:10:44 +08:00
shuaiplus 01154947ef feat: add Import & Export page and update Help page with new navigation 2026-03-02 00:10:44 +08:00
shuaiplus dc12a73ab3 fix: update deploy script to use consistent build command 2026-03-02 00:10:44 +08:00
shuaiplus 82131bd892 fix: update deploy script to use consistent build command 2026-03-02 00:10:44 +08:00
shuaiplus 9c9c76d82e chore: ensure newline at end of .gitignore file 2026-03-02 00:10:44 +08:00
shuaiplus ddf5901730 chore: ensure newline at end of .gitignore file 2026-03-02 00:10:44 +08:00
shuaiplus a1d38b76c6 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 65b57b00e2 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 705a716a80 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 15eb72a4b3 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 1a1b334f6c feat: add build script for consistent project building 2026-03-02 00:10:44 +08:00
shuaiplus 30884d7184 feat: add build script for consistent project building 2026-03-02 00:10:44 +08:00
shuaiplus 8d6835b665 feat: remove deprecated Bitwarden subprojects from the repository 2026-03-02 00:10:44 +08:00
shuaiplus 1ab8e1baa7 feat: remove deprecated Bitwarden subprojects from the repository 2026-03-02 00:10:44 +08:00
shuaiplus 189a7b9285 feat: update routing regex patterns for improved API path matching 2026-03-02 00:10:44 +08:00
shuaiplus d3d4755505 feat: update routing regex patterns for improved API path matching 2026-03-02 00:10:44 +08:00
shuaiplus 23a45913e0 feat: update favicon and logo images for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus a0b9f970c1 feat: update favicon and logo images for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus ace9f4f5ac feat: enhance security headers and update content security policy in response and HTML files 2026-03-02 00:10:44 +08:00
shuaiplus f20a71e8a8 feat: enhance security headers and update content security policy in response and HTML files 2026-03-02 00:10:44 +08:00
shuaiplus c0683016c3 feat: enhance deployment process and update dependencies
- Updated the deployment script to build the web application before deploying.
- Upgraded Wrangler dependency from 4.61.1 to 4.69.0.

feat: add import item limit and request body size limit

- Introduced a new limit for the maximum total items allowed in a single import (5000).
- Set a hard body size limit for JSON API endpoints (25 MB).

feat: validate KDF parameters during registration and password change

- Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements.
- Enhanced error handling for invalid KDF parameters during user registration and password change.

feat: clean up R2 files on user deletion

- Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata.

feat: verify folder ownership when creating or updating ciphers

- Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers.

fix: handle corrupted cipher data gracefully

- Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data.

feat: increment send access count atomically

- Added a method to atomically increment the access count for sends and return whether the update was successful.

fix: enforce request body size limits

- Implemented checks to reject oversized request bodies for non-file upload paths.

fix: update error handling for database initialization

- Enhanced error logging for database initialization failures while providing a generic message to clients.

feat: enhance security with Content Security Policy

- Added a Content Security Policy to the web application to improve security against XSS attacks.

fix: remove plaintext TOTP secret from localStorage

- Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server.

fix: ensure only PBKDF2 hash is sent for public send access

- Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
2026-03-02 00:10:44 +08:00
shuaiplus 7d5681665f feat: enhance deployment process and update dependencies
- Updated the deployment script to build the web application before deploying.
- Upgraded Wrangler dependency from 4.61.1 to 4.69.0.

feat: add import item limit and request body size limit

- Introduced a new limit for the maximum total items allowed in a single import (5000).
- Set a hard body size limit for JSON API endpoints (25 MB).

feat: validate KDF parameters during registration and password change

- Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements.
- Enhanced error handling for invalid KDF parameters during user registration and password change.

feat: clean up R2 files on user deletion

- Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata.

feat: verify folder ownership when creating or updating ciphers

- Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers.

fix: handle corrupted cipher data gracefully

- Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data.

feat: increment send access count atomically

- Added a method to atomically increment the access count for sends and return whether the update was successful.

fix: enforce request body size limits

- Implemented checks to reject oversized request bodies for non-file upload paths.

fix: update error handling for database initialization

- Enhanced error logging for database initialization failures while providing a generic message to clients.

feat: enhance security with Content Security Policy

- Added a Content Security Policy to the web application to improve security against XSS attacks.

fix: remove plaintext TOTP secret from localStorage

- Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server.

fix: ensure only PBKDF2 hash is sent for public send access

- Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
2026-03-02 00:10:44 +08:00
shuaiplus e9ace523e6 feat: enhance password security with server-side hashing and constant-time comparisons 2026-03-02 00:10:44 +08:00
shuaiplus 1a94f8dd44 feat: enhance password security with server-side hashing and constant-time comparisons 2026-03-02 00:10:44 +08:00
shuaiplus 4390251c1e feat: unify API rate limiting and enhance request budgets 2026-03-02 00:10:44 +08:00
shuaiplus 66f995d981 feat: unify API rate limiting and enhance request budgets 2026-03-02 00:10:44 +08:00
shuaiplus aef0c2f688 docs: update capability descriptions in README files for clarity 2026-03-02 00:10:44 +08:00
shuaiplus 234e3a5e96 docs: update capability descriptions in README files for clarity 2026-03-02 00:10:44 +08:00
shuaiplus 594ca0c7ea feat: add TOTP recovery code field to users table 2026-03-02 00:10:44 +08:00
shuaiplus d3b515fd99 feat: add TOTP recovery code field to users table 2026-03-02 00:10:44 +08:00
shuaiplus 26447cd9b4 docs: update README files for clarity on deployment steps and features 2026-03-02 00:10:44 +08:00
shuaiplus 68f66cf4e6 docs: update README files for clarity on deployment steps and features 2026-03-02 00:10:44 +08:00
shuaiplus f5a2523f91 feat: add JWT secret safety checks and warning page for insecure configurations 2026-03-02 00:10:44 +08:00
shuaiplus 9061ab52b6 feat: add JWT secret safety checks and warning page for insecure configurations 2026-03-02 00:10:44 +08:00
shuaiplus bbf4094943 fix: remove unnecessary zoom property from html in styles.css 2026-03-02 00:10:44 +08:00
shuaiplus 1d170baaaf fix: remove unnecessary zoom property from html in styles.css 2026-03-02 00:10:44 +08:00
shuaiplus 9f14bca99a feat(i18n): add internationalization support with English and Chinese translations 2026-03-02 00:10:44 +08:00
shuaiplus bacf27b936 feat(i18n): add internationalization support with English and Chinese translations 2026-03-02 00:10:44 +08:00
shuaiplus 8641df3cff feat: add recovery code functionality and device management 2026-03-02 00:10:44 +08:00
shuaiplus 1810e0aa7a feat: add recovery code functionality and device management 2026-03-02 00:10:44 +08:00
shuaiplus 8852127743 feat: update README files to reflect full user management and support for text and file sends 2026-03-02 00:10:44 +08:00
shuaiplus 3a650740a1 feat: update README files to reflect full user management and support for text and file sends 2026-03-02 00:10:44 +08:00
shuaiplus 053ce887f9 fix: update README to clarify NodeWarden as a third-party Bitwarden server 2026-03-02 00:10:44 +08:00
shuaiplus 9b490016aa fix: update README to clarify NodeWarden as a third-party Bitwarden server 2026-03-02 00:10:44 +08:00
shuaiplus 2fbe29a0d9 feat: add NodeWarden logo to README files for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus 0db5f957c8 feat: add NodeWarden logo to README files for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus 15b87025ad feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience 2026-03-02 00:10:44 +08:00
shuaiplus 8481e2756e feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience 2026-03-02 00:10:44 +08:00
shuaiplus 0e823e80a6 feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking 2026-03-02 00:10:44 +08:00
shuaiplus b7dfd1b3ad feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking 2026-03-02 00:10:44 +08:00
shuaiplus bb50617b16 feat: add PublicSendPage and SendsPage components for managing sends 2026-03-02 00:10:44 +08:00
shuaiplus 9c1c5e2c26 feat: add PublicSendPage and SendsPage components for managing sends 2026-03-02 00:10:44 +08:00
shuaiplus be3b68956b feat: add favicon and logo assets, update App component to use logo 2026-03-02 00:10:44 +08:00
shuaiplus 15e0a29bb1 feat: add favicon and logo assets, update App component to use logo 2026-03-02 00:10:44 +08:00
shuaiplus 0f132f4f43 feat: add SSH key utilities and improve field decryption 2026-03-02 00:10:44 +08:00
shuaiplus 205ccdad8b feat: add SSH key utilities and improve field decryption 2026-03-02 00:10:44 +08:00
shuaiplus 32c695c81f feat: enhance VaultPage and App layout with new UI components and styles 2026-03-02 00:10:44 +08:00
shuaiplus 389872d491 feat: enhance VaultPage and App layout with new UI components and styles 2026-03-02 00:10:44 +08:00
shuaiplus 651eb69bd6 feat: enhance authentication and settings UI 2026-03-02 00:10:44 +08:00
shuaiplus d7c41edad4 feat: enhance authentication and settings UI 2026-03-02 00:10:44 +08:00
shuaiplus 0cf8028087 feat: add cryptographic utilities and types for secure data handling 2026-03-02 00:10:44 +08:00
shuaiplus 5509492563 feat: add cryptographic utilities and types for secure data handling 2026-03-02 00:10:44 +08:00
shuaiplus 3494471cad feat: add toast notifications and dialog components for improved user interaction 2026-03-02 00:10:44 +08:00
shuaiplus 7c7d32de30 feat: add toast notifications and dialog components for improved user interaction 2026-03-02 00:10:44 +08:00
shuaiplus 59566f88e3 feat: implement vault locking mechanism with auto-lock settings and unlock functionality 2026-03-02 00:10:44 +08:00
shuaiplus 4831a0915c feat: implement vault locking mechanism with auto-lock settings and unlock functionality 2026-03-02 00:10:44 +08:00
shuaiplus 172f6626c0 feat: add QR code generation support and rate limiting for known device probes 2026-03-02 00:10:44 +08:00
shuaiplus 930f4f86cc feat: add QR code generation support and rate limiting for known device probes 2026-03-02 00:10:44 +08:00
shuaiplus 829008db7f Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI 2026-03-02 00:10:44 +08:00
shuaiplus ceb4bef9e4 Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI 2026-03-02 00:10:44 +08:00
shuaiplus 363aec1652 Add runtime configuration loader and styles for web application 2026-03-02 00:10:44 +08:00
shuaiplus c4c25efc50 Add runtime configuration loader and styles for web application 2026-03-02 00:10:44 +08:00
shuaiplus b8c4bcef0c Enhance styles for app layout and components 2026-03-02 00:10:44 +08:00
shuaiplus bda0cba1c6 Enhance styles for app layout and components 2026-03-02 00:10:44 +08:00
shuaiplus d0c8516021 Add global styles for web client interface 2026-03-02 00:10:44 +08:00
shuaiplus b10ce83ca0 Add global styles for web client interface 2026-03-02 00:10:44 +08:00
shuaiplus 1f4933c5d5 Implement code changes to enhance functionality and improve performance 2026-03-02 00:10:44 +08:00
shuaiplus ee784d18db Implement code changes to enhance functionality and improve performance 2026-03-02 00:10:44 +08:00
shuaiplus 4a37d742eb feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus ec9be40d6c feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus 6bbc7554c1 Refactor code structure for improved readability and maintainability 2026-03-02 00:10:44 +08:00
shuaiplus b21b031120 Refactor code structure for improved readability and maintainability 2026-03-02 00:10:44 +08:00
shuaiplus d80821edeb feat: enhance registration and password management UI with additional state handling 2026-03-02 00:10:44 +08:00
shuaiplus 90da97c945 feat: enhance registration and password management UI with additional state handling 2026-03-02 00:10:44 +08:00
shuaiplus 6e95d7a235 feat: implement admin user management and invite system 2026-03-02 00:10:44 +08:00
shuaiplus 39fbdc7e0e feat: implement admin user management and invite system 2026-03-02 00:10:44 +08:00
shuaiplus f9b084d09d feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 9359ce2a2c feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 4f82cf9d43 feat: add overlap grace period for refresh tokens to handle concurrent requests 2026-02-25 00:22:31 +08:00
shuaiplus 026aea03dc feat: add overlap grace period for refresh tokens to handle concurrent requests 2026-02-25 00:22:31 +08:00
shuaiplus bc0fd65b6b feat: add compatibility for custom fields handling in cipher creation and update 2026-02-25 00:10:11 +08:00
shuaiplus 6621738b02 feat: add compatibility for custom fields handling in cipher creation and update 2026-02-25 00:10:11 +08:00
shuaiplus 08114762bc feat: add compatibility for fido2Credentials counter and implement no-op device token update handler 2026-02-23 23:29:00 +08:00
shuaiplus 431cc0d5d7 feat: add compatibility for fido2Credentials counter and implement no-op device token update handler 2026-02-23 23:29:00 +08:00
shuaiplus 1dfa96611a feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus 2226bdd9ef feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus 36715645c6 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus f7a5966104 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus 3873d347aa enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus 747cad35f5 enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus 874d31e86b fix: ensure attachment size is formatted as string for compatibility with Bitwarden clients 2026-02-23 14:07:11 +08:00
shuaiplus c44436a5fd fix: ensure attachment size is formatted as string for compatibility with Bitwarden clients 2026-02-23 14:07:11 +08:00
shuaiplus cd7b5a361c feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus a3f074f38a feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus 9eddb91237 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus 8106364650 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus b2e8d3e00b feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus 2934ebd36d feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus a83e0d259e fix: increase max login attempts and improve two-factor token error response 2026-02-20 18:53:10 +08:00
shuaiplus 177d34ba54 fix: increase max login attempts and improve two-factor token error response 2026-02-20 18:53:10 +08:00
shuaiplus b6f2882cdf chore: update version to 1.1.0 and improve two-factor provider validation 2026-02-20 18:39:18 +08:00
shuaiplus 622a4ec506 chore: update version to 1.1.0 and improve two-factor provider validation 2026-02-20 18:39:18 +08:00
shuaiplus aaf5078c8a feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
shuaiplus 3f8a6d78d5 feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
shuaiplus 76d766d5d6 feat: extend CiphersImportRequest with additional fields for enhanced import functionality 2026-02-20 16:54:42 +08:00
shuaiplus 269055867b feat: extend CiphersImportRequest with additional fields for enhanced import functionality 2026-02-20 16:54:42 +08:00
shuaiplus cdbe87aac2 feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows.
- Introduced device management endpoints to handle known devices and their registration.
- Enhanced database schema to include devices and trusted two-factor tokens.
- Updated response handling to include two-factor token in successful login responses.
- Modified registration and login pages to guide users through enabling TOTP.
- Improved device identification and management utilities for better user experience.
2026-02-20 15:59:55 +08:00
shuaiplus 363a029618 feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows.
- Introduced device management endpoints to handle known devices and their registration.
- Enhanced database schema to include devices and trusted two-factor tokens.
- Updated response handling to include two-factor token in successful login responses.
- Modified registration and login pages to guide users through enabling TOTP.
- Improved device identification and management utilities for better user experience.
2026-02-20 15:59:55 +08:00
shuaiplus d1a43f2e95 fix: update TOTP field description for clarity in README files 2026-02-20 00:41:47 +08:00
shuaiplus 2b6852fb7f fix: update TOTP field description for clarity in README files 2026-02-20 00:41:47 +08:00
shuaiplus 8d6bcc327d fix: update JWT_SECRET description for clarity 2026-02-20 00:04:14 +08:00
shuaiplus e452dde3dc fix: update JWT_SECRET description for clarity 2026-02-20 00:04:14 +08:00
shuaiplus 6b8ee28e54 fix: update version to 1.0.0 in package.json and package-lock.json 2026-02-19 22:14:44 +08:00
shuaiplus 2f7dbc78d3 chore: remove temporary subproject references for cleanup 2026-02-19 21:39:12 +08:00
shuaiplus 1a22b108ca style: enhance register page styling with grid background and button effects 2026-02-19 21:13:59 +08:00
shuaiplus 40549147bd fix: update bitwarden server version to 2026.1.0 2026-02-19 19:58:33 +08:00
shuaiplus c0a390baa5 Refactor code structure for improved readability and maintainability 2026-02-19 18:57:23 +08:00
shuaiplus 7cdccde684 docs: add Star History section to README files 2026-02-19 16:08:08 +08:00
shuaiplus 9edaa647c4 feat(storage): add method to retrieve attachments by user ID for improved data handling 2026-02-19 02:27:56 +08:00
shuaiplus ba9710cdf0 fix(storage): optimize attachment retrieval by batching cipher IDs to improve performance 2026-02-19 01:42:55 +08:00
shuaiplus 69f4fde5a2 docs: update feature comparison table in README files for clarity and consistency 2026-02-18 21:29:51 +08:00
shuaiplus 2a747c996d feat(pagination): add pagination utility functions for handling page size and continuation tokens
- Introduced `PaginationRequest` interface to define pagination parameters.
- Implemented `parsePagination` function to extract and validate pagination parameters from a URL.
- Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens.
- Ensured that pagination respects maximum page size limits defined in configuration.
2026-02-18 20:59:46 +08:00
shuaiplus e1f1c6f865 fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting 2026-02-18 03:06:50 +08:00
shuaiplus 73db6c518b fix: track and clean up test-created cipher and folder IDs to prevent undecryptable items 2026-02-17 22:47:15 +08:00
shuaiplus 1d1cbd2c8e fix: enhance cipher handling to support unknown fields and improve database binding 2026-02-17 22:20:01 +08:00
shuaiplus 72ec21415b fix: adjust layout and improve JWT_SECRET instructions on registration page 2026-02-15 03:10:59 +08:00
shuaiplus 649f54f923 fix: update README to clarify deployment steps and features 2026-02-15 02:56:31 +08:00
shuaiplus beefe2227e Refactor code structure for improved readability and maintainability 2026-02-15 02:45:57 +08:00
shuaiplus 326e13adf0 fix: remove placeholder database_id from D1 database configuration 2026-02-15 02:25:26 +08:00
shuaiplus 6e1a8e7b5c fix: correct link to English README in Chinese version 2026-02-15 02:23:01 +08:00
shuaiplus c5d3052080 Refactor code structure for improved readability and maintainability 2026-02-15 02:21:55 +08:00
231 changed files with 67465 additions and 3267 deletions
+17
View File
@@ -0,0 +1,17 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
*.pid
+70
View File
@@ -0,0 +1,70 @@
name: "Bug Report"
description: "Report a reproducible bug / 反馈可复现问题"
title: "[Bug] "
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
感谢反馈,请尽量提供可复现信息,方便快速定位。
- type: checkboxes
id: checklist
attributes:
label: Pre-check / 提交前确认
options:
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
required: true
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
required: true
- type: input
id: version
attributes:
label: Version / 版本
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
placeholder: "1.0.0"
validations:
required: true
- type: textarea
id: reproduce_steps
attributes:
label: Steps to Reproduce / 复现步骤
placeholder: |
1. Start service with ...
2. Open ...
3. Click ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior / 预期行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior / 实际行为
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and Screenshots / 日志与截图
description: "Please paste key logs (docker logs / browser console / network errors)."
render: shell
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional Context / 补充信息
description: "Any workaround, frequency, impact scope, etc."
+12
View File
@@ -0,0 +1,12 @@
blank_issues_enabled: false
contact_links:
- name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki
about: |
Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
- name: Project Discussions / 讨论区
url: https://github.com/shuaiplus/nodewarden/discussions
about: |
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
@@ -0,0 +1,62 @@
name: "Feature Request"
description: "Suggest an improvement / 功能建议"
title: "[Feature] "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Proposals with clear use-case and expected value are easier to evaluate.
说明清晰的使用场景和价值,有助于快速评估。
- type: checkboxes
id: checklist
attributes:
label: Pre-check / 提交前确认
options:
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement / 现存问题
description: "What is difficult or missing today?"
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution / 建议方案
description: "Describe your expected behavior, UI flow, API changes, etc."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered / 备选方案
description: "Any alternatives or workarounds you've considered."
validations:
required: false
- type: textarea
id: impact
attributes:
label: Expected Impact / 预期价值
description: "Who benefits? Any performance/security/maintenance concerns?"
validations:
required: true
- type: input
id: scope
attributes:
label: Scope (Optional) / 影响范围(可选)
placeholder: "frontend / backend / docs / deployment"
- type: textarea
id: extra
attributes:
label: Additional Context / 补充信息
description: "Mockups, references, related links, etc."
+31
View File
@@ -0,0 +1,31 @@
## Summary
<!-- What changed and why? -->
## Change Type
- [ ] Bug fix
- [ ] Feature
- [ ] Compatibility update
- [ ] Documentation
- [ ] Refactor
## Cross-File Checklist
- [ ] I read `CONTRIBUTING.md`.
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
- [ ] User-facing text changes, if any, updated all locale files.
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
- [ ] No secrets, tokens, private deployment values, or real vault data are included.
## Checks
- [ ] `npx tsc -p tsconfig.json --noEmit`
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
- [ ] `npm run i18n:validate`
- [ ] `npm run build`
## Notes
<!-- Anything reviewers should pay special attention to? -->
+467
View File
@@ -0,0 +1,467 @@
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);
});
+142
View File
@@ -0,0 +1,142 @@
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
+51
View File
@@ -0,0 +1,51 @@
name: Sync Bitwarden global domains
on:
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
inputs:
bitwarden_ref:
description: "bitwarden/server ref to sync"
required: false
default: "main"
type: string
permissions:
contents: write
pull-requests: write
jobs:
sync-global-domains:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
- name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request
uses: peter-evans/create-pull-request@v6
with:
branch: chore/sync-bitwarden-global-domains
delete-branch: true
title: "chore: sync Bitwarden global domain rules"
commit-message: "chore: sync Bitwarden global domain rules"
body: |
Automated sync from bitwarden/server.
This PR only updates:
- `src/static/global_domains.bitwarden.json`
- `src/static/global_domains.bitwarden.meta.json`
`src/static/global_domains.custom.json` is intentionally left untouched.
add-paths: |
src/static/global_domains.bitwarden.json
src/static/global_domains.bitwarden.meta.json
+123 -8
View File
@@ -4,6 +4,11 @@ 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
@@ -11,18 +16,128 @@ permissions:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: |
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
git fetch upstream
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# 强制让当前分支完全等于 upstream
git reset --hard upstream/main
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
# 强制推送
git push origin main --force
- 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
+26 -1
View File
@@ -7,6 +7,7 @@ node_modules/
wrangler.my.toml
RELEASE_NOTES.md
tests/selfcheck.ts
problem.md
# Build output
dist/
@@ -25,7 +26,7 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
.vite-tailwind.err
# Environment
.env
.env.local
@@ -36,3 +37,27 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it)
# package-lock.json
tmp/
.tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/
wiki/
AGENTS.md
settings.json
.claude/
NodeWarden-compat/
.codex-upstream/
.codex-upstream/bitwarden-server/
.codex-upstream/bitwarden-clients/
.codex-upstream/bitwarden-web/
.codex-upstream/bitwarden-browser/
.reasonix/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
.mcp.json
opencode.jsonc
.cursor/
+133
View File
@@ -0,0 +1,133 @@
# Contributing to NodeWarden
Thanks for taking the time to improve NodeWarden.
NodeWarden is a Bitwarden-compatible server with a custom web vault, Cloudflare
Workers/D1 storage, attachment storage, imports/exports, and scheduled backups.
Small changes can affect official clients, backups, migrations, or locale files,
so please keep changes focused and check the related parts of the project.
## Before Opening an Issue
For bug reports, include enough detail for someone else to reproduce the problem:
- The client or browser you used.
- The page, API route, or action that failed.
- Screenshots, logs, or the exact error message.
- Whether the problem happened after sync, import, export, restore, upgrade, or
a fresh deployment.
Please do not report NodeWarden-specific problems to the official Bitwarden
team. This project is independent from Bitwarden.
## Pull Request Guidelines
Keep pull requests small enough to review. A good PR should explain:
- What changed and why.
- What user-facing behavior changed.
- Which related areas were checked.
- Which commands were run before submitting.
Avoid mixing unrelated refactors with feature or bug-fix work. If a cleanup is
needed before the real fix, mention that clearly in the PR.
## Areas That Need Extra Care
Some parts of the codebase are deliberately connected. When changing one of
these areas, check the related files before calling the work complete.
### Database Changes
Runtime schema lives in `src/services/storage-schema.ts`. The initial D1 schema
lives in `migrations/0001_init.sql`.
If you add or change a table, column, or index:
- Update both schema files.
- Bump `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
- Decide whether the data should be included in instance backup.
### Backup And Restore
Backup export and restore are whitelist-based. This protects old backups from
breaking when fields are removed and prevents transient or secret runtime data
from being exported by accident.
When adding persistent data, check:
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `webapp/src/lib/api/backup.ts`
Do not export runtime lock rows such as `backup.runner.lock.v1`. Do not import
retired sensitive fields such as `users.api_key`.
### Secrets And Provider Settings
Provider credentials must not be stored or exported as plain config JSON. Follow
the encrypted settings pattern in `src/services/backup-settings-crypto.ts`, or
document a replacement design before changing it.
### Bitwarden Client Compatibility
Official Bitwarden clients may send or expect fields that are not used directly
by the web vault. Cipher and sync changes should preserve unknown client fields
unless they are known-invalid or server-owned.
Check these files when changing vault item shape or sync behavior:
- `src/handlers/ciphers.ts`
- `src/handlers/sync.ts`
- `src/services/storage-cipher-repo.ts`
### Domain Rules
Equivalent-domain settings store both client/UI rule state and derived active
groups. Do not remove `equivalent_domains`, `custom_equivalent_domains`, or
`excluded_global_equivalent_domains` as duplicates without a migration and
compatibility plan.
### Accounts And Passwords
`users.master_password_hash` is for server-side login verification. It is not the
vault decryption key. Password changes, key material, `securityStamp`, and
refresh-token revocation must stay aligned.
Password hints are reminders, not recovery secrets. They must never contain the
master password, recovery codes, API keys, or anything that directly unlocks the
vault.
### i18n
Locale files are complete standalone bundles. When adding or changing user-facing
text, keep every locale in sync and run the validation script.
For new locales, update:
- `webapp/src/lib/i18n.ts`
- `webapp/src/lib/i18n/locales/*`
- `scripts/i18n-utils.cjs`
## Recommended Checks
For most backend or shared changes:
```sh
npx tsc -p tsconfig.json --noEmit
npm run build
```
For webapp text or locale changes:
```sh
npm run i18n:validate
npx tsc -p webapp/tsconfig.json --noEmit
npm run build
```
For documentation-only changes:
```sh
git diff --check
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0 30) scale(0.276)">
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
</g>
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+147 -52
View File
@@ -1,77 +1,170 @@
# NodeWarden
English[`README_EN.md`](./README_EN.md)
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**
<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>
<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>
</p>
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
</p>
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请向 Bitwarden 官方反馈问题。
> 本项目仅供学习交流使用,请定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden | NodeWarden | 说明 |
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| 多用户 | ✅ | | NodeWarden 定位单用户 |
| 组织/集合/成员权限 | ✅ | | 没必要实现 |
| 完整 2FATOTP/WebAuthn/Duo/Email | ✅ | ❌ | 没必要实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| Send | ✅ | ❌ | 基本没人用 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
---
# 快速开始
### 一键部署
**部署步骤:**
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. 打开部署后生成的链接,并根据网页提示完成后续操作。
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 本地开发
## 已测试客户端
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
## CLI 部署
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
```bash
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## 常见问题
## 主要特性
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
### PWA 渐进式 Web 应用
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
**Q: 可以多人使用吗?**
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
LGPL-3.0 License
@@ -80,10 +173,12 @@ LGPL-3.0 License
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
[![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)
+131 -54
View File
@@ -1,77 +1,155 @@
# NodeWarden
中文文档:[`README.md`](./README.md)
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
> Disclaimer
> - This project is for learning and communication only.
> - We are not responsible for any data loss. Regular vault backups are strongly recommended.
> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
</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 Table (vs Official Bitwarden Server)
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | | ✅ | Compatibility-focused implementation |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
| Multi-user | ✅ | | NodeWarden is single-user by design |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Full 2FA (TOTP/WebAuthn/Duo/Email) | | | Not necessary to implement |
| SSO / SCIM / Enterprise directory | ✅ | | Not necessary to implement |
| Send | ✅ | | Not necessary to implement |
| Emergency access | ✅ | | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0)
- ✅ Android app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested)
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | | | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | | **No email required** |
| TOTP / Steam TOTP | ✅ | | Includes `steam://` support |
| Multi-user | ✅ | | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
# Quick start
## Tested Clients
### One-click deploy
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
**Deploy steps:**
---
1. Fork this project (you don't need to fork it if you don't need to update it later).
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. Open the generated service URL and follow the on-page instructions.
## 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.
## Local development
| 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 |
This repo is a Cloudflare Workers TypeScript project (Wrangler).
> [!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
```bash
## 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
```
---
## FAQ
## Key Features
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
### PWA Progressive Web App
**Q: What if I forget the master password?**
A: It cant be recovered (end-to-end encryption). Keep it safe.
-**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
**Q: Can multiple people use it?**
A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden.
### 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
---
@@ -83,13 +161,12 @@ 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
- [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)
[![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)
+785
View File
@@ -0,0 +1,785 @@
# NodeWarden Passkey 登录研究记录
记录日期:2026-06-09
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
## 结论先放前面
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
下面按代码链路展开。
## 术语边界
这里有三个容易混淆的东西,文档后面严格区分:
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
## NodeWarden 现状
### 路由和入口
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token``/identity/accounts/prelogin``/api/accounts/register`
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
- `src/handlers/identity.ts`OAuth/token 兼容入口。
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
目前公开路由没有:
- `GET /identity/accounts/webauthn/assertion-options`
- `POST /identity/connect/token``grant_type=webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `GET/POST/PUT /api/webauthn`
### 注册链路
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts``registerAccount()`
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
- 随机生成 64 字节 vault symmetric key。
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
- POST `/api/accounts/register`,提交 `email``name``masterPasswordHash``key`、KDF 参数、invite code、`keys.publicKey``keys.encryptedPrivateKey`
后端 `src/handlers/accounts.ts``handleRegister()`
- 第一个用户自动成为 admin,后续用户需要 invite。
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`
- 保存 `users.key``users.private_key``users.public_key`、KDF 参数、`security_stamp`
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
### 主密码登录链路
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts``performPasswordLogin()`
-`deriveLoginHashLocally()` 得到 masterKey 和 client hash。
-`loginWithPassword()` POST `/identity/connect/token`
- token 成功后 `completeLogin()``token.Key` 和本地 masterKey 解开 vault key。
- 保存离线解锁记录。
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()``getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
后端 `src/handlers/identity.ts``handleToken()` 当前支持:
- `grant_type=password`
- `grant_type=client_credentials`
- `grant_type=refresh_token`
密码登录成功后会:
- 验证 IP 登录频率和用户状态。
- `AuthService.verifyPassword()` 验证 client hash。
- 处理 TOTP 或 remember 2FA token。
- 记录/更新 device。
- 生成 access token 和 refresh token。
- 返回 `Key``PrivateKey``AccountKeys`、KDF 参数、`UserDecryptionOptions`
### UserDecryptionOptions 和 sync
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
- `HasMasterPassword: true`
- `MasterPasswordUnlock`
- `TrustedDeviceOption: null`
- `KeyConnectorOption: null`
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
passkey 登录必须新增两类 shape
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
### 现有 passkey 相关代码
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
- `src/types/index.ts``CipherLogin.fido2Credentials`
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
- `webapp/src/lib/types.ts``CipherLoginPasskey`
这部分是“保存网站 passkey”,不是账户登录。
`src/utils/passkey.ts` 只有:
- `bytesToBase64Url()`
- `base64UrlToBytes()`
- `randomChallenge()`
- `parseClientDataJSON()`
缺少的核心能力:
- attestation verification
- assertion verification
- authenticator public key 格式处理
- signature verification
- sign counter 更新
- userHandle 与 user id 绑定验证
- origin/RP ID 验证
- challenge 过期和防重放
### 数据库和备份影响
NodeWarden schema 在这些地方需要同步:
- `migrations/0001_init.sql`
- `src/services/storage-schema.ts`
- `wrangler.toml` migrations
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `shared/backup-schema` 相关类型
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
## 官方 Bitwarden server 参考
上游代码位置:
- `.codex-upstream/bitwarden-server`
- 研究时 HEAD`574f3fd`
官方 server 里也有两个 WebAuthn 概念:
- 传统 WebAuthn 2FA`TwoFactorController``WebAuthnTokenProvider`
- 账户 passkey 登录:`WebAuthnLogin`
本项目要参考的是后者。
### 公开 passkey 登录入口
`src/Identity/Controllers/AccountsController.cs`
- `GET /accounts/webauthn/assertion-options`
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
- response 包含:
- `options`
- `token`
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
- scope 为 `Authentication`
- token 生命周期约 17 分钟
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- 新增 OAuth extension grant`grant_type=webauthn`
- 从 form 读取:
- `token`
- `deviceResponse`
- 解开 token,校验 scope 必须是 `Authentication`
- 反序列化 `AuthenticatorAssertionRawResponse`
- 调用 `AssertWebAuthnLoginCredential`
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
`src/Identity/IdentityServer/ApiClient.cs`
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
### 账户 passkey 管理接口
`src/Api/Auth/Controllers/WebAuthnController.cs`
官方 authenticated API
- `GET /webauthn`:列出账户 passkey credentials。
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
- `POST /webauthn`:保存新 credential。
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
- `POST /webauthn/{id}/delete`:删除 credential。
官方创建 credential 时保存:
- `name`
- `token`
- `deviceResponse`
- `supportsPrf`
- 可选 `encryptedUserKey`
- 可选 `encryptedPublicKey`
- 可选 `encryptedPrivateKey`
官方最多允许 5 个账户 passkey credentials。
### 官方 WebAuthnCredential 表
`src/Core/Auth/Entities/WebAuthnCredential.cs`
字段:
- `Id`
- `UserId`
- `Name`
- `PublicKey`
- `CredentialId`
- `Counter`
- `Type`
- `AaGuid`
- `EncryptedUserKey`
- `EncryptedPrivateKey`
- `EncryptedPublicKey`
- `SupportsPrf`
- `CreationDate`
- `RevisionDate`
SQLite migration`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
`GetPrfStatus()`
- `Unsupported``SupportsPrf` 为 false。
- `Supported`credential 支持 PRF,但还没有完整 encrypted keyset。
- `Enabled``EncryptedUserKey``EncryptedPrivateKey``EncryptedPublicKey` 都存在。
### 官方创建和认证策略
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- 使用 Fido2NetLib。
- `user.id` 是用户 id bytes。
- `user.name/displayName` 使用用户邮箱。
- 排除当前用户已有 credential ids。
- `residentKey: required`
- `userVerification: required`
- `attestation: none`
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `allowCredentials` 传空数组。
- `userVerification: required`
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
`CreateWebAuthnLoginCredentialCommand.cs`
- 限制每用户最多 5 个。
- 检查 credential id 在该用户下不能重复。
- FIDO `MakeNewCredentialAsync` 验证 attestation。
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
`AssertWebAuthnLoginCredentialCommand.cs`
- 先用 challenge cache 防重放。
- 从 assertion response 的 `userHandle` 解析出 user id。
- 加载该用户所有 WebAuthn credentials。
- 用 credential id 找到记录。
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
- 成功后更新 counter。
### 官方 PRF 解密协议
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
`WebAuthnPrfDecryptionOption` 字段:
- `EncryptedPrivateKey`
- `EncryptedUserKey`
- `CredentialId`
- `Transports`
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`
- 如果 credential 没有 PRF keysetpasskey 只能认证账号,不能解开 vault。
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`
## 官方 Bitwarden web/browser client 参考
上游代码位置:
- `.codex-upstream/bitwarden-clients`
- `.codex-upstream/bitwarden-browser`
- 两者研究时 HEAD 都是 `825f9be`browser repo 内容和 clients monorepo 对应。
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
### 登录按钮可见性
`libs/auth/src/angular/login/default-login-component.service.ts`
- 默认只对 `ClientType.Web` 开启 passkey 登录。
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- browser extension 覆盖逻辑:只对 Chromium 开启。
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
### Passkey 登录页
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
流程:
1. 进入 `/login-with-passkey` 后自动开始认证。
2.`webAuthnLoginService.getCredentialAssertionOptions()`
3.`webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`
4.`webAuthnLoginService.logIn(assertion)` 走 identity token grant。
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
7. 成功路由:
- Web`/vault`
- Browser`/tabs/vault`
- Desktop`/vault`
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
### 客户端 passkey 登录请求
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- GET `${identityUrl}/accounts/webauthn/assertion-options`
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `navigator.credentials.get({ publicKey: options })`
- 会主动加 PRF extension
- salt 是 `SHA-256("passwordless-login")`
- extension shape 是 `extensions.prf.eval.first`
-`credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
-`WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
- 构造 `WebAuthnLoginAssertionResponseRequest`
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- salt 常量:`passwordless-login`
- 先 SHA-256。
- 再用 HKDF expand 拆成 64 字节:
- `"enc"` 32 bytes
- `"mac"` 32 bytes
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
form encoded token 请求字段:
- `grant_type=webauthn`
- `token=<server assertion options token>`
- `deviceResponse=<JSON string>`
- 还会带 common device request 字段。
`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
`deviceResponse` shape
- `id`
- `rawId`
- `type`
- `extensions: {}`
- `response.authenticatorData`
- `response.signature`
- `response.clientDataJSON`
- `response.userHandle`
全部二进制字段使用 base64url。
### 客户端如何用 PRF 解 vault key
`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。
- `setUserKey()`
- 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。
- 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`
1. 用 PRF key unwrap `encryptedPrivateKey`
2. 用 private key decapsulate `encryptedUserKey`
3. 得到 user key,写入 `keyService`
核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。
### 官方 web 设置页注册 passkey
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
调用的 API
- `POST /webauthn/attestation-options`
- `POST /webauthn/assertion-options`
- `POST /webauthn`
- `GET /webauthn`
- `POST /webauthn/{id}/delete`
- `PUT /webauthn`
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
创建流程:
1. 用户做 secret verification。
2. 请求 attestation options。
3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`
4. 从 client extension results 判断 `supportsPrf`
5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`
- `allowCredentials` 锁定刚创建的 credential。
- 使用同一个 challenge、rpId、timeout、userVerification。
- 带 PRF eval salt。
6. 用 PRF key 和当前 user key 创建 rotateable keyset。
7. 保存 credential,带上 `encryptedUserKey``encryptedPublicKey``encryptedPrivateKey`
删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。
`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `Enabled = 0`
- `Supported = 1`
- `Unsupported = 2`
## NodeWarden 应实现的协议形状
### 公开登录流程
目标兼容官方客户端和 NodeWarden 自己 web
1. `GET /identity/accounts/webauthn/assertion-options`
- 生成 discoverable credential assertion options。
- `allowCredentials: []`
- `userVerification: "required"`
- 返回 `{ options, token }`
- token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。
2. Browser/web 调 `navigator.credentials.get()`
- NodeWarden 自己 web 也要使用 PRF extension。
- PRF salt 必须和官方一致:`SHA-256("passwordless-login")`
3. `POST /identity/connect/token`
- 支持 `grant_type=webauthn`
- 接收 `token``deviceResponse`、device fields。
- 解 token,校验 challenge/scope/过期。
- 验证 assertion。
-`userHandle` 找到 user id。
- 从 credential id 找到 passkey record。
- 更新 counter。
- 记录/更新 device。
- 返回 access/refresh token、`AccountKeys``UserDecryptionOptions.WebAuthnPrfOption`
如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwardenpasskey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
### 账户 passkey 管理流程
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`
- `GET /api/webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `POST /api/webauthn`
- `PUT /api/webauthn`
- `POST /api/webauthn/:id/delete`
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases
- `/webauthn`
- `/webauthn/attestation-options`
- `/webauthn/assertion-options`
- `/webauthn/:id/delete`
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`
### 建议新增表
按 NodeWarden 命名风格,建议用小写 snake_case
```sql
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential
ON webauthn_credentials(user_id, credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
```
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
PRF status 不必落库为枚举,可以由字段计算:
- `supports_prf = 0` => `Unsupported`
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
### Challenge/token 存储
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
- tokenHMAC/JWT 样式,绑定 `scope``challenge``userId?``rpId``createdAt``expiresAt`
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash``scope``user_id``expires_at``used_at`
- 登录 assertion options 是公开接口,不绑定 user idcreate/update/delete 管理流程应绑定 user id。
- 验证成功后立即 mark used。
建议 scopes
- `Authentication`
- `CreateCredential`
- `UpdateKeySet`
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
### 服务端 WebAuthn 验证库
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions``verifyRegistrationResponse``generateAuthenticationOptions``verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
## NodeWarden web 需要改的地方
### 登录页
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx``webapp/src/lib/app-auth.ts` 管。
新增:
- 登录页增加“使用 passkey 登录”按钮。
- 新增 `performPasskeyLogin()`
1. GET `/identity/accounts/webauthn/assertion-options`
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
3. `navigator.credentials.get()`,带 PRF salt。
4. POST `/identity/connect/token``grant_type=webauthn`
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
6. 用本地 PRF key 解出 user key。
7. 构造 `SessionState` 并进入 app。
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
### 设置页
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
新增:
- Passkey 列表。
- 新建 passkey dialog。
- 删除 passkey。
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
自己 web 的新建流程要和官方一致:
1. 已登录状态下先验证主密码或现有 session secret。
2. 请求 attestation options。
3. `navigator.credentials.create()``extensions.prf = {}`
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
### 客户端加密能力
NodeWarden web 当前已经有:
- PBKDF2
- HKDF expand
- Bitwarden EncString 加解密
- RSA-OAEP private key 加密
但 passkey PRF keyset 需要和官方策略对齐:
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
- `encryptedUserKey` 用对应 public key encapsulate user key。
- `encryptedPublicKey` 用于 key rotation。
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
## 扩展兼容要求
### 官方 browser extension
官方 extension passkey 登录入口在:
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- 只在 Chromium 开启。
如果要官方/派生扩展能对 NodeWarden passkey 登录:
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`
- token URL 必须支持 `grant_type=webauthn`
- API URL 必须能访问 `/webauthn` 管理接口。
- response 大小写和字段名要同时照顾 PascalCase/camelCaseNodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
### RP ID 和 origin
自己的 web
- RP ID 通常是站点 host,例如 `vault.example.com`
- origin 是 `https://vault.example.com`
官方 browser extension
- 扩展页面 origin 是 `chrome-extension://...`
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
建议配置化:
- `WEBAUTHN_RP_ID`
- `WEBAUTHN_RP_NAME`
- `WEBAUTHN_ALLOWED_ORIGINS`
默认可以从 request URL 推导 web origin,但生产建议显式配置。
## 安全约束
- 所有账户 passkey 必须 `userVerification: required`
- 登录 assertion 使用 discoverable credential`userHandle` 必须能解析成 user id 并和 credential 记录一致。
- challenge 必须有过期时间和一次性使用标记。
- PRF 输出绝不能传给 server,也不能写入日志。
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
- 每用户 credential 数量限制建议沿用官方 5 个。
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
- 审计日志建议新增:
- `auth.passkey.login.success`
- `auth.passkey.login.failed`
- `account.passkey.create`
- `account.passkey.delete`
- `account.passkey.encryption.enable`
- `account.passkey.rotate`
## 建议实施顺序
### 第一阶段:后端基础
1. 新增 `webauthn_credentials` 和 challenge 表。
2. 新增 storage repo。
3. 接入 WebAuthn 服务端验证库。
4. 实现 assertion options 和 `grant_type=webauthn`
5. token response 加 `WebAuthnPrfOption` shape。
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
### 第二阶段:账户 passkey 管理 API
1. 实现 `/api/webauthn``/webauthn` aliases。
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
3. 加 audit event。
4. 接入 backup export/import。
5. sync response 加 `WebAuthnPrfOptions`
### 第三阶段:NodeWarden 自己 web
1. 登录页 passkey 按钮和 `performPasskeyLogin()`
2. Passkey 设置页。
3. PRF keyset 创建、保存、删除、启用 encryption。
4. 浏览器能力判断和错误提示。
### 第四阶段:扩展兼容
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
2. 校对 `/config` 里 identity/api/web vault URL。
3. 校对 RP ID、allowed origins。
4. 必要时加兼容字段或 alias route。
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
## 待实现清单
- [ ] 设计并落库 `webauthn_credentials`
- [ ] 设计并落库 WebAuthn challenge/replay cache。
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
- [ ] `GET /identity/accounts/webauthn/assertion-options`
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`
- [ ] `UserDecryption.WebAuthnPrfOptions`
- [ ] `/api/webauthn` 管理接口。
- [ ] `/webauthn` 官方客户端 alias。
- [ ] NodeWarden web passkey 登录入口。
- [ ] NodeWarden web passkey 管理页。
- [ ] key rotation 时同步 rotate PRF keysets。
- [ ] backup export/import 覆盖新表。
- [ ] audit logs 覆盖 passkey 管理和登录。
## 关键文件索引
NodeWarden
- `src/router-public.ts`
- `src/router-authenticated.ts`
- `src/handlers/accounts.ts`
- `src/handlers/identity.ts`
- `src/handlers/sync.ts`
- `src/services/auth.ts`
- `src/services/storage-schema.ts`
- `src/services/storage-user-repo.ts`
- `src/services/storage-device-repo.ts`
- `src/utils/passkey.ts`
- `src/utils/user-decryption.ts`
- `src/types/index.ts`
- `webapp/src/lib/api/auth.ts`
- `webapp/src/lib/app-auth.ts`
- `webapp/src/components/AuthViews.tsx`
- `webapp/src/components/SettingsPage.tsx`
Bitwarden server
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
Bitwarden clients/browser
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
+159 -8
View File
@@ -1,5 +1,15 @@
PRAGMA foreign_keys = ON;
-- IMPORTANT:
-- This is the initial D1 schema. Keep it in sync with
-- src/services/storage-schema.ts (SCHEMA_STATEMENTS).
-- Any new table/column/index must be added to both places together.
--
-- WHEN CHANGING THIS:
-- - Also bump STORAGE_SCHEMA_VERSION in src/services/storage.ts.
-- - If the new table stores persistent data, update backup export/import.
-- - Keep src/services/storage-schema.ts idempotent for existing installs.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -9,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
master_password_hint TEXT,
master_password_hash TEXT NOT NULL,
key TEXT NOT NULL,
private_key TEXT,
@@ -18,10 +29,25 @@ CREATE TABLE IF NOT EXISTS users (
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
);
CREATE TABLE IF NOT EXISTS domain_settings (
user_id TEXT PRIMARY KEY,
equivalent_domains TEXT NOT NULL DEFAULT '[]',
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Per-user sync revision date
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
@@ -42,11 +68,15 @@ CREATE TABLE IF NOT EXISTS ciphers (
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -69,14 +99,143 @@ CREATE TABLE IF NOT EXISTS attachments (
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
CREATE TABLE IF NOT EXISTS sends (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type INTEGER NOT NULL,
name TEXT NOT NULL,
notes TEXT,
data TEXT NOT NULL,
key TEXT NOT NULL,
password_hash TEXT,
password_salt TEXT,
password_iterations INTEGER,
auth_type INTEGER NOT NULL DEFAULT 2,
emails TEXT,
max_access_count INTEGER,
access_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
hide_email INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expiration_date TEXT,
deletion_date TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
device_identifier TEXT,
device_session_stamp TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
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
);
CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at);
CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
action TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'system',
level TEXT NOT NULL DEFAULT 'info',
target_type TEXT,
target_id TEXT,
metadata TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at);
CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL,
device_identifier TEXT NOT NULL,
name TEXT NOT NULL,
type INTEGER NOT NULL,
session_stamp TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
banned INTEGER NOT NULL DEFAULT 0,
banned_at TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_identifier TEXT NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id
ON webauthn_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated
ON webauthn_credentials(user_id, updated_at);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
challenge_hash TEXT PRIMARY KEY,
scope TEXT NOT NULL,
user_id TEXT,
expires_at INTEGER NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
ON webauthn_challenges(user_id, scope);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY,
@@ -85,14 +244,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
+3154 -228
View File
File diff suppressed because it is too large Load Diff
+33 -5
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.0.0",
"version": "1.6.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -8,8 +8,16 @@
"type": "module",
"scripts": {
"dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy"
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"domains:sync": "node scripts/sync-global-domains.mjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
"bitwarden",
@@ -21,21 +29,41 @@
"cloudflare": {
"bindings": {
"JWT_SECRET": {
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
"description": "Use a strong random string (32+ characters recommended)"
},
"DB": {
"description": "D1 database for storing vault data"
},
"ATTACHMENTS": {
"description": "R2 bucket for storing file attachments"
},
"ATTACHMENTS_KV": {
"description": "Optional KV namespace fallback for attachment/send-file storage"
}
}
},
"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",
"wrangler": "^4.61.1"
"vite": "^7.3.1",
"wrangler": "^4.71.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",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Make `deploy:kv` idempotent across repeated builds.
*
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
* by name. The template ships without an id so fresh accounts can provision one
* on first deploy. In non-interactive builds, wrangler may try to create the
* same namespace again on later builds and fail with code 10014.
*/
const { execSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
const BINDING = 'ATTACHMENTS_KV';
const wrangler = (args) =>
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
function bindingBlockHasId(toml) {
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
return block ? /^\s*id\s*=/m.test(block) : false;
}
function expectedTitle(toml) {
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
}
function resolveId(title) {
const list = JSON.parse(wrangler('kv namespace list'));
const hit =
list.find((namespace) => namespace.title === title) ||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
if (hit) {
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
return hit.id;
}
const out = wrangler(`kv namespace create "${title}"`);
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
return id;
}
function main() {
let toml = fs.readFileSync(CONFIG, 'utf8');
if (bindingBlockHasId(toml)) {
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
return;
}
const id = resolveId(expectedTitle(toml));
toml = toml.replace(
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
`$1\nid = "${id}"`
);
fs.writeFileSync(CONFIG, toml);
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
}
main();
+44
View File
@@ -0,0 +1,44 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// CONTRACT:
// This list is the script-side locale source of truth. Keep it in sync with
// webapp/src/lib/i18n.ts whenever adding/removing a locale.
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
const localeFiles = [
['en', 'en.ts', 'en', 'English'],
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'],
];
function readLocale(fileName, variableName) {
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
code = code
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
.replace(/export default \w+;\s*$/m, '');
code += `\nresult = ${variableName};`;
const sandbox = { result: null };
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: fileName });
return sandbox.result;
}
function writeLocale(fileName, variableName, table, header) {
const body = JSON.stringify(table, null, 2);
fs.writeFileSync(
path.join(localeDir, fileName),
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
'utf8'
);
}
module.exports = {
localeFiles,
localeDir,
readLocale,
writeLocale,
};
+72
View File
@@ -0,0 +1,72 @@
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
// CONTRACT:
// This is the authoritative locale consistency gate. It checks key parity,
// placeholder parity, and accidental mostly-English locale files. Run after any
// user-facing text or locale-file change.
const locales = Object.fromEntries(
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
);
const base = locales.en;
const baseKeys = Object.keys(base).sort();
const placeholderRe = /\{\w+\}/g;
const errors = [];
const intentionallyEnglishKeys = new Set([
'txt_backup_destination_detail_note',
'txt_backup_protocol_webdav',
'txt_backup_protocol_s3',
'txt_backup_recommend_group_webdav',
'txt_backup_recommend_group_s3',
'txt_backup_destination_name_default_webdav',
'txt_backup_destination_name_default_s3',
'txt_dash',
'txt_text_3',
]);
const intentionallyEnglishPrefixes = [
'txt_log_action_',
'txt_log_meta_',
'txt_log_reason_',
'txt_log_target_type_',
'txt_log_trigger_',
];
function isIntentionallyEnglishKey(key) {
return intentionallyEnglishKeys.has(key) || intentionallyEnglishPrefixes.some((prefix) => key.startsWith(prefix));
}
for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort();
const missing = baseKeys.filter((key) => !(key in table));
const extra = keys.filter((key) => !baseKeys.includes(key));
if (missing.length || extra.length) {
errors.push({ locale, missing, extra });
}
for (const key of baseKeys) {
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
if (basePlaceholders !== localePlaceholders) {
errors.push({ locale, key, basePlaceholders, localePlaceholders });
}
}
if (locale !== 'en') {
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !isIntentionallyEnglishKey(key));
if (sameAsEnglish.length > 40) {
errors.push({
locale,
sameAsEnglishCount: sameAsEnglish.length,
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
});
}
}
}
console.log(JSON.stringify({
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
errors,
}, null, 2));
if (errors.length) {
process.exit(1);
}
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
const path = require('node:path');
const distDir = path.resolve(__dirname, '..', 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const DEFAULT_REF = 'main';
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
function parseArgs(argv) {
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--ref' && argv[i + 1]) {
args.ref = argv[i + 1];
i += 1;
} else if (arg.startsWith('--ref=')) {
args.ref = arg.slice('--ref='.length);
}
}
return args;
}
function rawUrl(ref, filePath) {
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'NodeWarden global domains sync',
Accept: 'text/plain',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
}
return response.text();
}
function parseEnumTypes(source) {
const map = new Map();
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
if (!enumMatch) {
throw new Error('GlobalEquivalentDomainsType enum was not found');
}
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
let match;
while ((match = entryRe.exec(body)) !== null) {
map.set(match[1], Number(match[2]));
}
if (!map.size) {
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
}
return map;
}
function parseStringList(source) {
const domains = [];
const stringRe = /"((?:\\.|[^"\\])*)"/g;
let match;
while ((match = stringRe.exec(source)) !== null) {
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
}
return Array.from(new Set(domains.filter(Boolean)));
}
function parseGlobalDomains(source, enumTypes) {
const out = [];
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
let match;
while ((match = addRe.exec(source)) !== null) {
const name = match[1];
const type = enumTypes.get(name);
if (!Number.isInteger(type)) {
throw new Error(`GlobalDomains references unknown enum value ${name}`);
}
const domains = parseStringList(match[2]);
if (domains.length < 2) {
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
}
out.push({
type,
domains,
excluded: false,
});
}
if (!out.length) {
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
}
return out;
}
function formatRulesJson(rules) {
return `[\n${rules.map((rule) => ` ${JSON.stringify(rule)}`).join(',\n')}\n]`;
}
function formatMetaJson(meta) {
return JSON.stringify(meta, null, 2);
}
const { ref } = parseArgs(process.argv.slice(2));
const enumUrl = rawUrl(ref, ENUM_PATH);
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
const [enumSource, staticStoreSource] = await Promise.all([
fetchText(enumUrl),
fetchText(staticStoreUrl),
]);
const enumTypes = parseEnumTypes(enumSource);
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
const rulesJson = formatRulesJson(rules);
async function readJsonFile(filePath) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch {
return null;
}
}
const existingRules = await readJsonFile(OUT_FILE);
const existingMeta = await readJsonFile(META_FILE);
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
const unchangedRef = existingMeta?.ref === ref;
const meta = {
source: 'https://github.com/bitwarden/server',
ref,
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
? existingMeta.generatedAt
: new Date().toISOString(),
rulesCount: rules.length,
domainsCount,
sourceFiles: [
ENUM_PATH,
STATIC_STORE_PATH,
],
sourceUrls: [
enumUrl,
staticStoreUrl,
],
};
await mkdir(OUTPUT_DIR, { recursive: true });
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
await writeFile(META_FILE, `${formatMetaJson(meta)}\n`, 'utf8');
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = '1.6.0';
+159
View File
@@ -0,0 +1,159 @@
// Shared backup settings types used by both Worker and webapp code.
//
// CONTRACT:
// Keep this file serializable and provider-neutral. Runtime state is operational
// metadata; destination fields can contain provider credentials and must be
// encrypted by src/services/backup-settings-crypto.ts before storage/export.
// User-facing provider names should use canonical values here. Legacy aliases
// belong in backend normalization, not in this shared type.
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_S3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav';
export interface S3BackupDestination {
endpoint: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
rootPath: string;
}
export interface WebDavBackupDestination {
baseUrl: string;
username: string;
password: string;
remotePath: string;
}
export type BackupDestinationConfig =
| S3BackupDestination
| WebDavBackupDestination;
export interface BackupRuntimeState {
lastAttemptAt: string | null;
lastAttemptLocalDate: string | null;
lastSuccessAt: string | null;
lastErrorAt: string | null;
lastErrorMessage: string | null;
lastUploadedFileName: string | null;
lastUploadedSizeBytes: number | null;
lastUploadedDestination: string | null;
}
export interface BackupScheduleConfig {
enabled: boolean;
intervalHours: number;
startTime: string;
timezone: string;
retentionCount: number | null;
}
export interface BackupDestinationRecord {
id: string;
name: string;
type: BackupDestinationType;
includeAttachments: boolean;
destination: BackupDestinationConfig;
schedule: BackupScheduleConfig;
runtime: BackupRuntimeState;
}
export interface BackupSettings {
destinations: BackupDestinationRecord[];
}
export function createBackupRandomId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `backup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function createDefaultBackupRuntimeState(): BackupRuntimeState {
return {
lastAttemptAt: null,
lastAttemptLocalDate: null,
lastSuccessAt: null,
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: null,
lastUploadedSizeBytes: null,
lastUploadedDestination: null,
};
}
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
return {
enabled: false,
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
startTime: BACKUP_DEFAULT_START_TIME,
timezone,
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
};
}
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
if (type === 's3') {
return {
endpoint: '',
bucket: '',
region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '',
secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
};
}
return {
baseUrl: '',
username: '',
password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
};
}
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
if (type === 's3') return `S3 ${index}`;
return `WebDAV ${index}`;
}
export interface CreateBackupDestinationRecordOptions {
id?: string;
name?: string;
timezone?: string;
}
export function createBackupDestinationRecord(
type: BackupDestinationType,
index: number,
options: CreateBackupDestinationRecordOptions = {}
): BackupDestinationRecord {
return {
id: options.id || createBackupRandomId(),
name: options.name || createDefaultBackupDestinationName(type, index),
type,
includeAttachments: false,
destination: createDefaultBackupDestinationConfig(type),
schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE),
runtime: createDefaultBackupRuntimeState(),
};
}
export function createDefaultBackupSettings(
timezone: string = BACKUP_DEFAULT_TIMEZONE,
options: { destinationName?: string } = {}
): BackupSettings {
return {
destinations: [
createBackupDestinationRecord('webdav', 1, {
timezone,
name: options.destinationName,
}),
],
};
}
+151
View File
@@ -0,0 +1,151 @@
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
'ac.cn',
'com.cn',
'edu.cn',
'gov.cn',
'net.cn',
'org.cn',
'ah.cn',
'bj.cn',
'cq.cn',
'fj.cn',
'gd.cn',
'gs.cn',
'gx.cn',
'gz.cn',
'ha.cn',
'hb.cn',
'he.cn',
'hi.cn',
'hk.cn',
'hl.cn',
'hn.cn',
'jl.cn',
'js.cn',
'jx.cn',
'ln.cn',
'mo.cn',
'nm.cn',
'nx.cn',
'qh.cn',
'sc.cn',
'sd.cn',
'sh.cn',
'sn.cn',
'sx.cn',
'tj.cn',
'tw.cn',
'xj.cn',
'xz.cn',
'yn.cn',
'zj.cn',
'co.uk',
'org.uk',
'net.uk',
'ac.uk',
'gov.uk',
'com.au',
'net.au',
'org.au',
'edu.au',
'gov.au',
'co.nz',
'org.nz',
'net.nz',
'com.br',
'com.mx',
'com.ar',
'com.tr',
'com.sg',
'com.my',
'com.hk',
'com.tw',
'co.jp',
'ne.jp',
'or.jp',
'co.kr',
'or.kr',
'co.in',
'firm.in',
'net.in',
'org.in',
'co.id',
'or.id',
'web.id',
'co.il',
'org.il',
'co.za',
'com.sa',
'com.ph',
'com.vn',
'com.pk',
'com.bd',
'com.ng',
'github.io',
'pages.dev',
'workers.dev',
'cloudflareaccess.com',
'vercel.app',
'netlify.app',
'web.app',
'firebaseapp.com',
'herokuapp.com',
'fly.dev',
'railway.app',
'render.com',
'onrender.com',
]);
function extractHost(input: string): string {
let raw = input.trim().toLowerCase();
if (!raw) return '';
raw = raw.replace(/\\/g, '/');
try {
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
const parsed = new URL(candidate);
raw = parsed.hostname;
} catch {
raw = raw.split(/[/?#]/, 1)[0] || '';
const atIndex = raw.lastIndexOf('@');
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
if (raw.startsWith('[')) return '';
const colonIndex = raw.lastIndexOf(':');
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
}
return raw
.replace(/^\*+\./, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '');
}
function isValidHost(host: string): boolean {
if (!host || host.length > 253 || !host.includes('.')) return false;
if (host.includes('..') || /[:/\s]/.test(host)) return false;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
return host.split('.').every((label) => (
label.length > 0
&& label.length <= 63
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
));
}
export function normalizeEquivalentDomain(value: unknown): string {
const host = extractHost(String(value || ''));
if (!isValidHost(host)) return '';
const labels = host.split('.');
for (let index = 0; index < labels.length; index += 1) {
const suffix = labels.slice(index).join('.');
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
if (index === 0) return '';
return labels.slice(index - 1).join('.');
}
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
}
export function isValidEquivalentDomain(value: unknown): boolean {
return !!normalizeEquivalentDomain(value);
}
+69 -15
View File
@@ -5,33 +5,63 @@
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
// Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 300,
// Send access token lifetime in seconds.
// Send 访问令牌有效期(秒)。
sendAccessTokenTtlSeconds: 300,
// Minimum required JWT secret length.
// JWT 密钥最小长度要求。
jwtSecretMinLength: 32,
// Default PBKDF2 iterations for account creation/prelogin fallback.
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
defaultKdfIterations: 600000,
// clientSecret length
// clientSecret 长度
clientSecretLength: 30,
},
rateLimit: {
// Max failed login attempts before temporary lock.
// 触发临时锁定前允许的最大登录失败次数。
loginMaxAttempts: 5,
loginMaxAttempts: 10,
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// Write API request budget per minute.
// 写操作 API 每分钟请求配额。
apiWriteRequestsPerMinute: 120,
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// Authenticated API request budget per user per minute (all reads & writes combined).
// 认证 API 每用户每分钟请求配额(读写合计)
apiRequestsPerMinute: 200,
// Public (unauthenticated) request budget per IP per minute.
// 公开(未认证)接口每 IP 每分钟请求配额。
publicRequestsPerMinute: 60,
// Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30,
// Password hint lookup budget per IP per minute.
// 密码提示查询接口每 IP 每分钟请求配额。
passwordHintRequestsPerMinute: 1,
// Password hint lookup budget per IP per hour.
// 密码提示查询接口每 IP 每小时请求配额。
passwordHintRequestsPerHour: 3,
// Register endpoint budget per IP per minute.
// 注册接口每 IP 每分钟请求配额。
registerRequestsPerMinute: 5,
// Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
@@ -41,15 +71,9 @@
// Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
},
cleanup: {
// Minimum interval between refresh-token cleanup runs.
@@ -67,6 +91,14 @@
// 附件上传大小上限(字节)。
maxFileSizeBytes: 100 * 1024 * 1024,
},
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 100 * 1024 * 1024,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
},
pagination: {
// Default page size when client does not specify pageSize.
// 客户端未传 pageSize 时的默认分页大小。
@@ -87,6 +119,12 @@
// In-memory /api/sync response cache TTL (milliseconds).
// /api/sync 内存缓存有效期(毫秒)。
syncResponseTtlMs: 30 * 1000,
// Max size of a single cached /api/sync body in bytes.
// 单个 /api/sync 缓存响应允许的最大字节数。
syncResponseMaxBodyBytes: 512 * 1024,
// Max total in-memory bytes used by /api/sync cache per isolate.
// 每个 isolate 中 /api/sync 缓存允许占用的最大总字节数。
syncResponseMaxTotalBytes: 2 * 1024 * 1024,
// Max in-memory /api/sync cache entries per isolate.
// 每个 isolate 的 /api/sync 最大缓存条目数。
syncResponseMaxEntries: 64,
@@ -95,10 +133,26 @@
// Max IDs per SQL batch when moving ciphers in bulk.
// 批量移动密码项时每批 SQL 的最大 ID 数量。
bulkMoveChunkSize: 200,
// Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000,
// Small fixed concurrency for blob/attachment batch cleanup work.
// 附件 / blob 批量清理时的保守并发数。
attachmentDeleteConcurrency: 4,
},
request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
// JSON 接口请求 body 大小上限(字节),文件上传接口除外。
maxBodyBytes: 25 * 1024 * 1024,
},
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0',
bitwardenServerVersion: '2026.4.1',
// Official 2026.4.x clients need this flag to receive and use cipher.key.
// Hiding existing item keys makes item-key encrypted vault data unreadable.
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
cipherKeyEncryptionFeatureEnabled: true,
},
} as const;
+465
View File
@@ -0,0 +1,465 @@
import type { Env } from '../types';
import type { BackupDestinationRecord } from '../services/backup-config';
import {
BACKUP_SCHEDULER_WINDOW_MINUTES,
requireBackupDestination,
hasBackupSlotBetween,
isBackupDueNow,
loadBackupSettings,
} from '../services/backup-config';
import {
createRemoteBackupTransferSession,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
} from '../services/backup-uploader';
import { getBlobObject } from '../services/blob-store';
import { StorageService } from '../services/storage';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
interface BackupJobState {
token: string;
reason: string;
acquiredAt: string;
touchedAt: string;
expiresAtMs: number;
}
interface RemoteAttachmentChunkRequest {
destination: BackupDestinationRecord;
attachments: Array<{
blobName: string;
}>;
}
interface RemoteAttachmentDownloadRequest {
destination: BackupDestinationRecord;
blobName?: string | null;
}
interface RemoteAttachmentBatchDownloadRequest {
destination: BackupDestinationRecord;
blobNames?: string[] | null;
}
interface ConfiguredBackupRunRequest {
actorUserId?: string | null;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
targetDeviceIdentifier?: string | null;
trigger?: 'manual' | 'scheduled';
}
interface RemoteBackupRestoreRequest {
actorUserId?: string | null;
allowChecksumMismatch?: boolean;
auditMetadata?: Record<string, unknown> | null;
destinationId?: string | null;
path?: string | null;
replaceExisting?: boolean;
targetDeviceIdentifier?: string | null;
}
function badRequest(message: string, status: number = 400): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
export class BackupTransferRunner {
private lastHeartbeatAt = 0;
constructor(
private readonly state: DurableObjectState,
private readonly env: Env
) {
}
private async acquireJob(reason: string): Promise<string | null> {
const nowMs = Date.now();
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
return null;
}
const token = crypto.randomUUID();
const nowIso = new Date(nowMs).toISOString();
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
token,
reason,
acquiredAt: nowIso,
touchedAt: nowIso,
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
this.lastHeartbeatAt = 0;
return token;
}
private async touchJob(token: string): Promise<void> {
const nowMs = Date.now();
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
this.lastHeartbeatAt = nowMs;
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token !== token) return;
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
...current,
touchedAt: new Date(nowMs).toISOString(),
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
});
}
private async releaseJob(token: string): Promise<void> {
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
if (current?.token === token) {
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
}
}
private async runConfiguredBackup(request: Request): Promise<Response> {
let body: ConfiguredBackupRunRequest;
try {
body = await request.json<ConfiguredBackupRunRequest>();
} catch {
return badRequest('Backup run payload is invalid');
}
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
const actorUserId = String(body.actorUserId || '').trim() || null;
if (trigger === 'manual' && !actorUserId) {
return badRequest('Manual backup run requires an actor');
}
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const progress = actorUserId
? async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(
this.env,
actorUserId,
event,
String(body.targetDeviceIdentifier || '').trim() || null
);
}
: null;
const result = await executeConfiguredBackup(
this.env,
storage,
actorUserId,
trigger,
body.destinationId || null,
() => this.touchJob(token),
progress,
body.auditMetadata || null
);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
return new Response(JSON.stringify({
object: 'backup-runner-result',
result,
settings,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async runScheduledBackups(): Promise<Response> {
const token = await this.acquireJob('scheduled');
if (!token) {
return badRequest('Another backup run is already in progress', 409);
}
let completed = 0;
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
let scanStartMs = Date.now();
while (true) {
await this.touchJob(token);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const now = new Date();
const dueDestinations = settings.destinations.filter((destination) =>
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
);
if (!dueDestinations.length) {
break;
}
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await this.touchJob(token);
await executeConfiguredBackup(
this.env,
storage,
null,
'scheduled',
destination.id,
() => this.touchJob(token)
);
completed += 1;
}
}
return new Response(JSON.stringify({
ok: true,
completed,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
} finally {
await this.releaseJob(token);
}
}
private async restoreRemoteBackup(request: Request): Promise<Response> {
let body: RemoteBackupRestoreRequest;
try {
body = await request.json<RemoteBackupRestoreRequest>();
} catch {
return badRequest('Remote restore payload is invalid');
}
const actorUserId = String(body.actorUserId || '').trim() || null;
if (!actorUserId) {
return badRequest('Remote restore requires an actor');
}
const token = await this.acquireJob(`restore:${actorUserId}`);
if (!token) {
return badRequest('Another backup or restore run is already in progress', 409);
}
try {
await this.touchJob(token);
const storage = new StorageService(this.env.DB);
const settings = await loadBackupSettings(storage, this.env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const restoreFileNameFromPath = path.split('/').pop() || path;
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
const replaceExisting = !!body.replaceExisting;
await notifyUserBackupRestoreProgress(
this.env,
actorUserId,
{
operation: 'backup-restore',
source: 'remote',
step: 'remote_fetch_archive',
fileName: restoreFileNameFromPath,
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return badRequest('Remote backup file checksum does not match its filename');
}
const result = await importAndAuditRemoteBackupFile(
this.env,
storage,
actorUserId,
remoteFile,
destination,
path,
replaceExisting,
!checksumOk,
body.auditMetadata || null,
targetDeviceIdentifier
);
return new Response(JSON.stringify(result.result), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
} catch (error) {
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
} finally {
await this.releaseJob(token);
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method !== 'POST') {
return badRequest('Not found', 404);
}
if (url.pathname === '/internal/run-configured-backup') {
return this.runConfiguredBackup(request);
}
if (url.pathname === '/internal/run-scheduled-backups') {
return this.runScheduledBackups();
}
if (url.pathname === '/internal/restore-remote-backup') {
return this.restoreRemoteBackup(request);
}
if (url.pathname === '/internal/download-remote-attachment') {
let body: RemoteAttachmentDownloadRequest;
try {
body = await request.json<RemoteAttachmentDownloadRequest>();
} catch {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) {
return badRequest('Remote attachment download payload is invalid');
}
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
if (!file) {
return badRequest('Remote attachment not found', 404);
}
return new Response(file.bytes, {
status: 200,
headers: {
'Content-Type': file.contentType || 'application/octet-stream',
'Cache-Control': 'no-store',
},
});
}
if (url.pathname === '/internal/download-remote-attachment-batch') {
let body: RemoteAttachmentBatchDownloadRequest;
try {
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
} catch {
return badRequest('Remote attachment batch download payload is invalid');
}
const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim())
.filter(Boolean)
));
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid');
}
const encoder = new TextEncoder();
const entries: Array<{ blobName: string; path: string }> = [];
const files: Record<string, Uint8Array> = {};
for (let i = 0; i < blobNames.length; i += 1) {
const blobName = blobNames[i];
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
if (!file) continue;
const path = `files/${i}.bin`;
entries.push({ blobName, path });
files[path] = file.bytes;
}
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
return new Response(zipSync(files), {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Cache-Control': 'no-store',
},
});
}
if (url.pathname !== '/internal/upload-attachment-chunk') {
return badRequest('Not found', 404);
}
let body: RemoteAttachmentChunkRequest;
try {
body = await request.json<RemoteAttachmentChunkRequest>();
} catch {
return badRequest('Attachment chunk payload is invalid');
}
if (!body?.destination || !Array.isArray(body.attachments)) {
return badRequest('Attachment chunk payload is invalid');
}
const remoteSession = createRemoteBackupTransferSession(body.destination);
let uploaded = 0;
for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim();
if (!blobName) {
return badRequest('Attachment chunk payload is invalid');
}
const object = await getBlobObject(this.env, blobName);
if (!object) {
return badRequest(`Attachment blob missing for ${blobName}`, 409);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
contentType: object.contentType,
});
uploaded += 1;
}
return new Response(JSON.stringify({
ok: true,
uploaded,
}), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
}
+492
View File
@@ -0,0 +1,492 @@
import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type HubProtocol = 'json' | 'messagepack';
interface WsAttachment {
userId: string;
handshakeComplete: boolean;
protocol: HubProtocol;
deviceIdentifier: string | null;
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
function encodeUtf8(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
function encodeMsgPackInteger(value: number): Uint8Array {
const normalized = Math.trunc(value);
if (normalized >= 0 && normalized <= 0x7f) {
return new Uint8Array([normalized]);
}
if (normalized >= 0 && normalized <= 0xff) {
return new Uint8Array([0xcc, normalized]);
}
if (normalized >= 0 && normalized <= 0xffff) {
return new Uint8Array([0xcd, normalized >> 8, normalized & 0xff]);
}
const safe = normalized >>> 0;
return new Uint8Array([
0xce,
(safe >>> 24) & 0xff,
(safe >>> 16) & 0xff,
(safe >>> 8) & 0xff,
safe & 0xff,
]);
}
function encodeMsgPackString(value: string): Uint8Array {
const bytes = encodeUtf8(value);
const len = bytes.length;
if (len < 32) {
return concatBytes([new Uint8Array([0xa0 | len]), bytes]);
}
if (len <= 0xff) {
return concatBytes([new Uint8Array([0xd9, len]), bytes]);
}
return concatBytes([new Uint8Array([0xda, (len >> 8) & 0xff, len & 0xff]), bytes]);
}
function encodeMsgPackTimestamp(date: Date): Uint8Array {
const seconds = BigInt(Math.floor(date.getTime() / 1000));
const nanos = BigInt(date.getMilliseconds()) * 1000000n;
const timestamp = (nanos << 34n) | seconds;
const payload = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
payload[i] = Number((timestamp >> BigInt((7 - i) * 8)) & 0xffn);
}
return concatBytes([new Uint8Array([0xc7, 0x08, 0xff]), payload]);
}
function encodeMsgPackArray(values: unknown[]): Uint8Array {
const items = values.map(encodeMsgPack);
const len = items.length;
const header =
len < 16
? new Uint8Array([0x90 | len])
: new Uint8Array([0xdc, (len >> 8) & 0xff, len & 0xff]);
return concatBytes([header, ...items]);
}
function encodeMsgPackMap(value: Record<string, unknown>): Uint8Array {
const entries = Object.entries(value);
const len = entries.length;
const header =
len < 16
? new Uint8Array([0x80 | len])
: new Uint8Array([0xde, (len >> 8) & 0xff, len & 0xff]);
const chunks: Uint8Array[] = [header];
for (const [key, entryValue] of entries) {
chunks.push(encodeMsgPackString(key), encodeMsgPack(entryValue));
}
return concatBytes(chunks);
}
function encodeMsgPack(value: unknown): Uint8Array {
if (value === null || value === undefined) return new Uint8Array([0xc0]);
if (value instanceof Date) return encodeMsgPackTimestamp(value);
if (typeof value === 'string') return encodeMsgPackString(value);
if (typeof value === 'number') return encodeMsgPackInteger(value);
if (typeof value === 'boolean') return new Uint8Array([value ? 0xc3 : 0xc2]);
if (Array.isArray(value)) return encodeMsgPackArray(value);
if (value instanceof Uint8Array) {
const len = value.length;
if (len <= 0xff) return concatBytes([new Uint8Array([0xc4, len]), value]);
return concatBytes([new Uint8Array([0xc5, (len >> 8) & 0xff, len & 0xff]), value]);
}
return encodeMsgPackMap(value as Record<string, unknown>);
}
function frameSignalRBinary(payload: Uint8Array): Uint8Array {
const len = payload.length;
const prefix: number[] = [];
let value = len;
do {
let current = value & 0x7f;
value >>>= 7;
if (value > 0) current |= 0x80;
prefix.push(current);
} while (value > 0);
return concatBytes([new Uint8Array(prefix), payload]);
}
function buildSignalRJsonInvocation(
updateType: number,
payload: Record<string, unknown>,
contextId: string | null
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
arguments: [
{
ContextId: contextId,
Type: updateType,
Payload: payload,
},
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRMessagePackInvocation(
updateType: number,
messagePayload: Record<string, unknown>,
contextId: string | null
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
const encodedPayload = encodeMsgPack([
1,
{},
null,
'ReceiveMessage',
[
{
ContextId: contextId,
Type: updateType,
Payload: messagePayload,
},
],
]);
return frameSignalRBinary(encodedPayload);
}
export class NotificationsHub extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair(
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
)
);
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/internal/notify' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
revisionDate?: string;
userId?: string;
contextId?: string | null;
updateType?: number;
targetDeviceIdentifier?: string | null;
payload?: Record<string, unknown> | null;
} | null;
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
const contextId = String(body?.contextId || '').trim() || null;
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
const payload = body?.payload && typeof body.payload === 'object'
? body.payload
: {
UserId: userId,
Date: revisionDate,
};
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/online' && request.method === 'GET') {
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
if (url.pathname !== '/notifications/hub') {
return new Response('Not found', { status: 404 });
}
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected websocket', { status: 426 });
}
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
if (!requestUserId) {
return new Response('Unauthorized', { status: 401 });
}
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
const tags: string[] = [];
if (requestDeviceIdentifier) {
tags.push(`device:${requestDeviceIdentifier}`);
}
this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({
userId: requestUserId,
handshakeComplete: false,
protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier,
} satisfies WsAttachment);
return new Response(null, {
status: 101,
webSocket: client,
});
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment) return;
if (!attachment.handshakeComplete) {
const text = decodeIncomingMessage(message);
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
for (const frame of frames) {
try {
const handshake = JSON.parse(frame) as { protocol?: string };
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
attachment.handshakeComplete = true;
ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return;
} catch {
// Ignore malformed pre-handshake payloads.
}
}
return;
}
if (typeof message !== 'string') {
try {
ws.send(message);
} catch {
// ignore send errors on echo
}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
}
private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>();
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier);
}
return Array.from(out);
}
private broadcastMessage(
updateType: number,
payload: Record<string, unknown>,
contextId: string | null,
targetDeviceIdentifier: string | null
): void {
const sockets = targetDeviceIdentifier
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
: this.ctx.getWebSockets();
if (sockets.length === 0) return;
for (const ws of sockets) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete) continue;
try {
if (attachment.protocol === 'json') {
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
} else {
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
}
} catch {
try {
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
}
private broadcastDeviceStatus(userId: string): void {
this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{
UserId: userId,
Date: new Date().toISOString(),
},
null,
null
);
}
}
export function notifyUserVaultSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
}
export function notifyUserLogout(
env: Env,
userId: string,
targetDeviceIdentifier?: string | null
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
}
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const response = await stub.fetch('https://notifications/internal/online');
if (!response.ok) return [];
const body = (await response.json().catch(() => null)) as { deviceIdentifiers?: string[] } | null;
return Array.isArray(body?.deviceIdentifiers) ? body.deviceIdentifiers.filter((value) => !!String(value || '').trim()) : [];
} catch {
return [];
}
}
async function notifyUserUpdate(
env: Env,
userId: string,
updateType: number,
revisionDate: string,
contextId: string | null,
targetDeviceIdentifier: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-NodeWarden-UserId': userId,
},
body: JSON.stringify({
revisionDate,
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
},
}),
});
} catch (error) {
console.error('Failed to broadcast realtime notification:', error);
}
}
export async function notifyUserBackupProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
source?: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
const revisionDate = progress.timestamp || new Date().toISOString();
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-NodeWarden-UserId': userId,
},
body: JSON.stringify({
revisionDate,
contextId: null,
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
...progress,
},
}),
});
} catch (error) {
console.error('Failed to broadcast backup progress:', error);
}
}
export async function notifyUserBackupRestoreProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore';
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
}
+488
View File
@@ -0,0 +1,488 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey';
import {
accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus,
accountPasskeyTokenTtlMs,
buildWebAuthnPrfOption,
createAccountPasskeyToken,
getAccountPasskeyRpConfig,
isSerializedEncString,
normalizeAccountPasskeyName,
normalizeAuthenticationResponse,
normalizeRegistrationResponse,
normalizeTransports,
sha256Base64Url,
toSimpleWebAuthnCredential,
userHandleToUserId,
userIdToWebAuthnUserId,
verifyAccountPasskeyToken,
} from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
const MAX_ACCOUNT_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
return parseBodyObject(await request.json());
} catch {
return null;
}
}
async function verifyUserSecret(
env: Env,
user: User,
body: Record<string, any>
): Promise<boolean> {
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
if (!secret) return false;
const storedHash = String(user.masterPasswordHash || '').trim();
if (!storedHash) return false;
const auth = new AuthService(env);
return auth.verifyPassword(secret, storedHash, user.email);
}
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
const err = error instanceof Error ? error : null;
console.error('Account passkey handler failed', {
stage,
name: err?.name || typeof error,
message: err?.message || String(error),
stack: err?.stack,
...details,
});
}
function passkeySetupStageMessage(stage: string): string {
if (stage === 'verify_master_password') return 'verifying master password';
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
if (stage === 'generate_options') return 'generating passkey options';
if (stage === 'save_challenge') return 'saving passkey challenge';
if (stage === 'create_token') return 'creating passkey challenge token';
return 'preparing passkey setup';
}
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
}
function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
} {
if (!hasCompletePrfKeySet(body)) {
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
}
const encryptedUserKey = String(body.encryptedUserKey).trim();
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
throw new Error('Invalid encrypted key set');
}
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
}
async function saveChallenge(
storage: StorageService,
scope: AccountPasskeyChallengeScope,
challenge: string,
userId: string | null
): Promise<void> {
const now = Date.now();
await storage.saveAccountPasskeyChallenge({
challengeHash: await sha256Base64Url(challenge),
scope,
userId,
expiresAt: now + accountPasskeyTokenTtlMs(scope),
usedAt: null,
createdAt: now,
});
}
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: [],
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'Authentication', options.challenge, null);
const token = await createAccountPasskeyToken(env, {
scope: 'Authentication',
challenge: options.challenge,
userId: null,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function assertAccountPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
input: {
token: string;
deviceResponse: unknown;
scope: 'Authentication' | 'UpdateKeySet';
expectedUserId?: string | null;
}
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
if (!payload) {
throw new Error('Passkey challenge token is invalid or expired');
}
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
throw new Error('Passkey challenge token does not match this user');
}
const response = normalizeAuthenticationResponse(input.deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(
challengeHash,
input.scope,
payload.userId,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential) {
throw new Error('Passkey is not registered for this server');
}
if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
if (!resolvedUserId || resolvedUserId !== credential.userId) {
throw new Error('Passkey user handle does not match this credential');
}
const user = await storage.getUserById(resolvedUserId);
if (!user || user.status !== 'active') {
throw new Error('Passkey user is not available');
}
const { origins } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: true,
advancedFIDOConfig: { userVerification: 'required' },
});
if (!verification.verified || !verification.authenticationInfo.userVerified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return { user, credential };
}
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
return jsonResponse({
data: credentials.map(accountPasskeyCredentialToResponse),
Data: credentials.map(accountPasskeyCredentialToResponse),
object: 'list',
Object: 'list',
continuationToken: null,
ContinuationToken: null,
});
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let stage = 'verify_master_password';
try {
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
stage = 'load_existing_credentials';
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
stage = 'generate_options';
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
});
(options as any).extensions = {
...((options as any).extensions || {}),
prf: {},
};
stage = 'save_challenge';
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
stage = 'create_token';
const token = await createAccountPasskeyToken(env, {
scope: 'CreateCredential',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
} catch (error) {
logAccountPasskeyHandlerError(stage, error, { userId });
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
}
}
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
const requestedId = String(body.credentialId || body.id || '').trim();
if (requestedId) {
credentials = credentials.filter((credential) => credential.id === requestedId);
if (!credentials.length) return errorResponse('Account passkey not found', 404);
}
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
const token = await createAccountPasskeyToken(env, {
scope: 'UpdateKeySet',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const storage = new StorageService(env.DB);
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
if (!payload || payload.userId !== userId) {
return errorResponse('Passkey challenge token is invalid or expired', 400);
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const { origins } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
requireUserPresence: true,
requireUserVerification: true,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
const transports = normalizeTransports(registrationResponse.response.transports);
const credential: AccountPasskeyCredential = {
id: generateUUID(),
userId,
name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: prfKeySet.encryptedUserKey,
encryptedPublicKey: prfKeySet.encryptedPublicKey,
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
supportsPrf,
createdAt: now,
updatedAt: now,
};
await storage.saveAccountPasskeyCredential(credential);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.create',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
prfStatus: accountPasskeyPrfStatus(credential),
...auditRequestMetadata(request),
},
});
return jsonResponse(accountPasskeyCredentialToResponse(credential));
}
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
return errorResponse('Encrypted passkey key set is required', 400);
}
const storage = new StorageService(env.DB);
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
assertion = await assertAccountPasskeyCredential(request, env, storage, {
token: String(body.token || ''),
deviceResponse: body.deviceResponse,
scope: 'UpdateKeySet',
expectedUserId: userId,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
}
const updated = await storage.updateAccountPasskeyEncryption(
userId,
assertion.credential.credentialId,
prfKeySet.encryptedUserKey,
prfKeySet.encryptedPublicKey,
prfKeySet.encryptedPrivateKey
);
if (!updated) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.encryption.enable',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: assertion.credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
if (!deleted) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.delete',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credentialId,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
return buildWebAuthnPrfOption(credential);
}
+985 -71
View File
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
import { Env, User, Invite } from '../types';
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
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('');
}
function buildInviteLink(request: Request, code: string): string {
const url = new URL(request.url);
return `${url.origin}/?invite=${encodeURIComponent(code)}`;
}
async function writeAuditLog(
storage: StorageService,
actorUserId: string | null,
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null,
request?: Request
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId,
action,
targetType,
targetId,
category: action.startsWith('admin.user.') ? 'security' : 'system',
level: action.startsWith('admin.user.') ? 'security' : 'info',
metadata: {
...(metadata || {}),
...(request ? auditRequestMetadata(request) : {}),
},
});
}
function toInviteResponse(request: Request, invite: Invite): Record<string, unknown> {
return {
code: invite.code,
status: invite.status,
createdBy: invite.createdBy,
usedBy: invite.usedBy,
createdAt: invite.createdAt,
updatedAt: invite.updatedAt,
expiresAt: invite.expiresAt,
inviteLink: buildInviteLink(request, invite.code),
object: 'invite',
};
}
// GET /api/admin/users
export async function handleAdminListUsers(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const users = await storage.getAllUsers();
return jsonResponse({
data: users.map(user => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
twoFactorEnabled: !!user.totpSecret,
creationDate: user.createdAt,
revisionDate: user.updatedAt,
object: 'user',
})),
object: 'list',
continuationToken: null,
});
}
// GET /api/admin/logs
export async function handleAdminListAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const url = new URL(request.url);
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
const category = String(url.searchParams.get('category') || '').trim() || null;
const level = String(url.searchParams.get('level') || '').trim() || null;
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
const from = String(url.searchParams.get('from') || '').trim() || null;
const to = String(url.searchParams.get('to') || '').trim() || null;
const storage = new StorageService(env.DB);
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
return jsonResponse({
data: result.logs.map(log => ({
id: log.id,
actorUserId: log.actorUserId,
actorEmail: log.actorEmail,
action: log.action,
category: log.category,
level: log.level,
targetType: log.targetType,
targetId: log.targetId,
targetUserEmail: log.targetUserEmail,
metadata: log.metadata,
createdAt: log.createdAt,
object: 'auditLog',
})),
total: result.total,
limit,
offset,
hasMore: result.hasMore,
object: 'list',
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
});
}
// GET /api/admin/logs/settings
export async function handleAdminGetAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
return jsonResponse({
object: 'auditLogSettings',
...await getAuditLogSettings(storage),
});
}
// PUT /api/admin/logs/settings
export async function handleAdminUpdateAuditLogSettings(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const storage = new StorageService(env.DB);
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
return jsonResponse({
object: 'auditLogSettings',
...settings,
});
}
// DELETE /api/admin/logs
export async function handleAdminClearAuditLogs(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs();
return jsonResponse({ object: 'auditLogClear', deleted });
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {};
try {
body = await request.json();
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours)
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7;
const now = new Date();
const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000);
const invite: Invite = {
code: randomHex(20),
createdBy: actorUser.id,
usedBy: null,
expiresAt: expiresAt.toISOString(),
status: 'active',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
expiresInHours,
}, request);
return jsonResponse(toInviteResponse(request, invite), 201);
}
// GET /api/admin/invites
export async function handleAdminListInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const includeInactive = url.searchParams.get('includeInactive') === 'true';
const invites = await storage.listInvites(includeInactive);
return jsonResponse({
data: invites.map(invite => toInviteResponse(request, invite)),
object: 'list',
continuationToken: null,
});
}
// DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite(
request: Request,
env: Env,
actorUser: User,
code: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code);
if (!revoked) {
return errorResponse('Invite not found or already inactive', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
return new Response(null, { status: 204 });
}
// DELETE /api/admin/invites
export async function handleAdminDeleteAllInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
}, request);
return jsonResponse({ deleted }, 200);
}
// PUT /api/admin/users/:id/status
export async function handleAdminSetUserStatus(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) {
return errorResponse('status must be active or banned', 400);
}
if (targetUserId === actorUser.id && nextStatus !== 'active') {
return errorResponse('You cannot ban yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
target.status = nextStatus;
target.updatedAt = new Date().toISOString();
await storage.saveUser(target);
if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id);
}
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
}, request);
return jsonResponse({
id: target.id,
email: target.email,
role: target.role,
status: target.status,
object: 'user',
});
}
// DELETE /api/admin/users/:id
export async function handleAdminDeleteUser(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (targetUserId === actorUser.id) {
return errorResponse('You cannot delete yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
// Clean up R2 files before DB cascade deletes the metadata rows.
// 1. Attachment files (keyed by cipherId/attachmentId)
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
}
}
// 2. Send files (keyed by sends/sendId/fileId)
const sends = await storage.getAllSends(target.id);
for (const send of sends) {
if (send.type === 1) { // SendType.File
try {
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
} catch { /* non-file send or bad data, skip */ }
}
}
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
AuthService.invalidateUserCache(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
targetEmail: target.email,
}, request);
return new Response(null, { status: 204 });
}
+242 -70
View File
@@ -1,10 +1,56 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
import { 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 { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
import {
createAttachmentUploadToken,
createFileDownloadToken,
verifyAttachmentUploadToken,
verifyFileDownloadToken,
} from '../utils/jwt';
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
deleteBlobObject,
getAttachmentObjectKey,
getBlobObject,
getBlobStorageMaxBytes,
putBlobObject,
} from '../services/blob-store';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeAttachmentAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'attachment',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -14,9 +60,65 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
// Get R2 object path for attachment
function getAttachmentPath(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
async function runWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return;
const limit = Math.max(1, concurrency);
for (let index = 0; index < items.length; index += limit) {
await Promise.all(items.slice(index, index + limit).map(worker));
}
}
async function processAttachmentUpload(
request: Request,
env: Env,
attachment: Attachment,
cipherId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes);
const upload = await parseDirectUploadPayload(request, {
expectedSize: Number(attachment.size) || 0,
maxFileSize,
tooLargeMessage: `File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`,
});
if (upload instanceof Response) {
return upload;
}
const path = getAttachmentObjectKey(cipherId, attachment.id);
try {
await putBlobObject(env, path, upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
cipherId,
attachmentId: attachment.id,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('KV object too large')) {
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
}
return errorResponse('Attachment storage is not configured', 500);
}
if (upload.size !== attachment.size) {
attachment.size = upload.size;
attachment.sizeName = formatSize(upload.size);
await storage.saveAttachment(attachment);
}
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return new Response(null, { status: 201 });
}
// POST /api/ciphers/{cipherId}/attachment/v2
@@ -71,24 +173,29 @@ export async function handleCreateAttachment(
await storage.addAttachmentToCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createAttachmentUploadToken(userId, cipherId, attachmentId, jwtSecret);
return jsonResponse({
object: 'attachment-fileUpload',
attachmentId: attachmentId,
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
fileUploadType: 0, // Direct upload
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
fileUploadType: 1,
cipherResponse: cipherToResponse(updatedCipher!, attachments),
});
}
// Maximum file size: 100MB
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
// Upload attachment file content
export async function handleUploadAttachment(
@@ -112,54 +219,45 @@ export async function handleUploadAttachment(
return errorResponse('Attachment not found', 404);
}
// Check content-length header for size limit
const contentLength = request.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
return processAttachmentUpload(request, env, attachment, cipherId);
}
export async function handlePublicUploadAttachment(
request: Request,
env: Env,
cipherId: string,
attachmentId: string
): Promise<Response> {
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
// Get the file from multipart form data
const contentType = request.headers.get('content-type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const formData = await request.formData();
const file = formData.get('data') as File | null;
if (!file) {
return errorResponse('No file uploaded', 400);
const claims = await verifyAttachmentUploadToken(token, jwtSecret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
return errorResponse('Token mismatch', 401);
}
// Check actual file size
if (file.size > MAX_FILE_SIZE) {
return errorResponse('File too large. Maximum size is 100MB', 413);
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404);
}
// Store file in R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.put(path, file.stream(), {
httpMetadata: {
contentType: 'application/octet-stream',
},
customMetadata: {
cipherId: cipherId,
attachmentId: attachmentId,
},
});
// Update attachment size if different
const actualSize = file.size;
if (actualSize !== attachment.size) {
attachment.size = actualSize;
attachment.sizeName = formatSize(actualSize);
await storage.saveAttachment(attachment);
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
return new Response(null, { status: 200 });
return processAttachmentUpload(request, env, attachment, cipherId);
}
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
@@ -184,6 +282,7 @@ export async function handleGetAttachment(
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
// Generate short-lived download token
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
@@ -194,11 +293,69 @@ export async function handleGetAttachment(
return jsonResponse({
object: 'attachment',
id: attachment.id,
id: responseAttachment.id,
url: downloadUrl,
fileName: responseAttachment.fileName,
key: responseAttachment.key,
size: String(Number(responseAttachment.size) || 0),
sizeName: responseAttachment.sizeName,
});
}
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
export async function handleUpdateAttachmentMetadata(
request: Request,
env: Env,
userId: string,
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
let body: { fileName?: string | null; key?: string | null };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
return errorResponse('No metadata fields supplied', 400);
}
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
const fileName = String(body.fileName || '').trim();
if (!fileName) return errorResponse('fileName is required', 400);
attachment.fileName = fileName;
}
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
const key = body.key == null ? null : String(body.key || '').trim();
attachment.key = key || null;
}
await storage.saveAttachment(attachment);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return jsonResponse({
object: 'attachment',
id: attachment.id,
fileName: attachment.fileName,
key: attachment.key,
size: Number(attachment.size) || 0,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
});
}
@@ -242,9 +399,8 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Attachment not found', 404);
}
// Get file from R2
const path = getAttachmentPath(cipherId, attachmentId);
const object = await env.ATTACHMENTS.get(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
@@ -257,7 +413,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
@@ -287,18 +443,22 @@ export async function handleDeleteAttachment(
return errorResponse('Attachment not found', 404);
}
// Delete file from R2
const path = getAttachmentPath(cipherId, attachmentId);
await env.ATTACHMENTS.delete(path);
const path = getAttachmentObjectKey(cipherId, attachmentId);
await deleteBlobObject(env, path);
// Delete attachment metadata
await storage.deleteAttachment(attachmentId);
// Remove attachment from cipher
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId,
cipherId,
size: attachment.size,
});
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
@@ -314,12 +474,24 @@ export async function deleteAllAttachmentsForCipher(
env: Env,
cipherId: string
): Promise<void> {
const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) {
const path = getAttachmentPath(cipherId, attachment.id);
await env.ATTACHMENTS.delete(path);
await storage.deleteAttachment(attachment.id);
}
await deleteAllAttachmentsForCiphers(env, [cipherId]);
}
export async function deleteAllAttachmentsForCiphers(
env: Env,
cipherIds: string[]
): Promise<void> {
const storage = new StorageService(env.DB);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
);
if (!attachments.length) return;
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path);
});
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
}
File diff suppressed because it is too large Load Diff
+1063 -41
View File
File diff suppressed because it is too large Load Diff
+602
View File
@@ -0,0 +1,602 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
if (!value?.id || !value.creationDate) return null;
return {
id: String(value.id),
creationDate: String(value.creationDate),
};
}
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
return !!(device.encryptedUserKey && device.encryptedPublicKey);
}
function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: displayName,
name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
object: 'device',
};
return response as DeviceResponse;
}
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: String(device.deviceNote || '').trim() || device.name,
name: String(device.deviceNote || '').trim() || device.name,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
object: 'protectedDevice',
};
return response as ProtectedDeviceWireResponse;
}
function parseKeysBody(body: any, fallback?: Device): {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
} {
return {
encryptedUserKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
? body?.encryptedUserKey ?? null
: fallback?.encryptedUserKey ?? null,
encryptedPublicKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
? body?.encryptedPublicKey ?? null
: fallback?.encryptedPublicKey ?? null,
encryptedPrivateKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
? body?.encryptedPrivateKey ?? null
: fallback?.encryptedPrivateKey ?? null,
};
}
async function readJsonBody(request: Request): Promise<any> {
try {
return await request.json();
} catch {
return null;
}
}
function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
// - X-Device-Identifier: client device identifier
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
if (!email || !deviceIdentifier) {
return jsonResponse(false);
}
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
return jsonResponse(known);
}
// GET /api/devices
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const devices = await storage.getDevicesByUserId(userId);
return jsonResponse({
data: devices.map((device) => buildDeviceResponse(device)),
object: 'list',
continuationToken: null,
});
}
// GET /api/devices/identifier/:deviceIdentifier
export async function handleGetDeviceByIdentifier(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildDeviceResponse(device));
}
// GET /api/devices/:deviceIdentifier
export async function handleGetDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
// GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const [devices, trusted, onlineDeviceIdentifiers] = await Promise.all([
storage.getDevicesByUserId(userId),
storage.getTrustedDeviceTokenSummariesByUserId(userId),
getOnlineUserDevices(env, userId),
]);
const onlineSet = new Set(onlineDeviceIdentifiers);
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
for (const row of trusted) {
trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount });
}
const knownIdentifiers = new Set<string>();
const data = devices.map(device => {
knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return {
...buildDeviceResponse(device),
online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
object: 'device',
};
});
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
const placeholderDevice: Device = {
userId,
deviceIdentifier: row.deviceIdentifier,
name: 'Unknown device',
type: 14,
sessionStamp: '',
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
object: 'device',
});
}
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
}
// DELETE /api/devices/authorized
export async function handleRevokeAllTrustedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByUserId(userId);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/authorized/:deviceIdentifier
export async function handleRevokeTrustedDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
// POST /api/devices/authorized/:deviceIdentifier/permanent
// Upgrades an existing active 2FA remember-token record to permanent trust.
export async function handleTrustDevicePermanently(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
if (!updated) return errorResponse('Device is not currently trusted', 409);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.permanent',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { updated, ...auditRequestMetadata(request) },
});
return jsonResponse({
success: true,
updated,
trustedUntil: new Date(PERMANENT_TRUST_EXPIRES_AT_MS).toISOString(),
});
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const name = parseDeviceName(body?.name);
if (!name) return errorResponse('Device name is required', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateDeviceName(userId, normalized, name);
if (!updated) return errorResponse('Device not found', 404);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.name.update',
category: 'device',
level: 'info',
targetType: 'device',
targetId: normalized,
metadata: { name, ...auditRequestMetadata(request) },
});
return jsonResponse(buildDeviceResponse(device));
}
// DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId),
storage.deleteDevicesByUserId(userId),
]);
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(userId);
notifyUserLogout(env, userId, null);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.delete_all',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
export async function handleUpdateDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
if (!updated) {
return errorResponse('Device not found', 404);
}
const nextDevice = await storage.getDevice(userId, normalized);
return jsonResponse(buildDeviceResponse(nextDevice || device));
}
// POST /api/devices/update-trust
export async function handleUpdateDeviceTrust(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const currentDeviceIdentifier =
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
const updates: Array<{
deviceIdentifier: string;
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
};
}> = [];
if (currentDeviceIdentifier && body?.currentDevice) {
updates.push({
deviceIdentifier: currentDeviceIdentifier,
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
});
}
if (Array.isArray(body?.otherDevices)) {
for (const item of body.otherDevices) {
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
if (!deviceIdentifier) continue;
updates.push({
deviceIdentifier,
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
});
}
}
let updatedCount = 0;
for (const update of updates) {
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
if (ok) updatedCount++;
}
return jsonResponse({ success: true, updated: updatedCount });
}
// POST /api/devices/untrust
export async function handleUntrustDevices(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
const removed = await storage.clearDeviceKeys(userId, devices);
for (const deviceIdentifier of devices) {
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.trust.revoke_batch',
category: 'device',
level: 'security',
targetType: 'user',
targetId: userId,
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, removed });
}
// POST /api/devices/:deviceIdentifier/retrieve-keys
export async function handleRetrieveDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildProtectedDeviceResponse(device));
}
// POST /api/devices/:id/deactivate
export async function handleDeactivateDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized);
}
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.deactivate',
category: 'device',
level: 'security',
targetType: 'device',
targetId: normalized,
metadata: { deleted, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: deleted });
}
// PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
export async function handleUpdateDeviceWebPushAuth(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/clear-token
export async function handleClearDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+85
View File
@@ -0,0 +1,85 @@
import type { Env } from '../types';
import { StorageService } from '../services/storage';
import {
buildDomainsResponse,
customRulesToActiveEquivalentDomains,
normalizeCustomEquivalentDomains,
normalizeEquivalentDomains,
normalizeExcludedGlobalTypes,
} from '../services/domain-rules';
import { errorResponse, jsonResponse } from '../utils/response';
// CONTRACT:
// This route accepts both camelCase and PascalCase Bitwarden-compatible payloads.
// It stores custom rules, then derives equivalentDomains from the non-excluded
// custom rules. Keep this behavior aligned with backup import/export and
// src/services/storage-domain-rules-repo.ts.
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
}
return undefined;
}
async function readPayload(request: Request): Promise<Record<string, unknown>> {
try {
const parsed = await request.json();
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
} catch {
return {};
}
}
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const settings = await storage.getUserDomainSettings(userId);
return jsonResponse(buildDomainsResponse(
settings.equivalentDomains,
settings.customEquivalentDomains,
settings.excludedGlobalEquivalentDomains
));
}
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const payload = await readPayload(request);
const current = await storage.getUserDomainSettings(userId);
const equivalentDomainsRaw = firstPresent(payload, [
'equivalentDomains',
'EquivalentDomains',
]);
const customEquivalentDomainsRaw = firstPresent(payload, [
'customEquivalentDomains',
'CustomEquivalentDomains',
]);
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
'excludedGlobalEquivalentDomains',
'ExcludedGlobalEquivalentDomains',
// Some older compatible clients send the excluded type list under this key.
'globalEquivalentDomains',
'GlobalEquivalentDomains',
]);
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
? (equivalentDomainsRaw === undefined
? current.customEquivalentDomains
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
? current.excludedGlobalEquivalentDomains
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
const settings = await storage.getUserDomainSettings(userId);
if (!settings) {
return errorResponse('Domain settings unavailable', 500);
}
return jsonResponse(buildDomainsResponse(
settings.equivalentDomains,
settings.customEquivalentDomains,
settings.excludedGlobalEquivalentDomains
));
}
+70 -3
View File
@@ -1,8 +1,41 @@
import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
async function writeFolderAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'folder',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
@@ -10,6 +43,7 @@ function folderToResponse(folder: Folder): FolderResponse {
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
};
}
@@ -75,7 +109,8 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
};
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200);
}
@@ -102,7 +137,8 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder));
}
@@ -118,7 +154,38 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
return new Response(null, { status: 204 });
}
// POST /api/folders/delete
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
if (!ids.length) {
return errorResponse('Folder ids are required', 400);
}
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length,
});
}
return new Response(null, { status: 204 });
}
+773 -64
View File
@@ -4,6 +4,209 @@ 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 { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends';
import {
buildAccountKeys,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import {
assertAccountPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values.
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
}
return null;
}
async function resolveDeviceSession(
storage: StorageService,
userId: string,
deviceInfo: ReturnType<typeof readAuthRequestDeviceInfo>
): Promise<{ identifier: string; sessionStamp: string } | null> {
if (!deviceInfo.deviceIdentifier) return null;
const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier);
const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID();
return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
}
function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
}
function parseCookieValue(request: Request, name: string): string | null {
const rawCookie = String(request.headers.get('Cookie') || '').trim();
if (!rawCookie) return null;
for (const part of rawCookie.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key !== name) continue;
const value = rest.join('=').trim();
return value ? decodeURIComponent(value) : 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];
if (value != null) return value;
}
return undefined;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:';
const parts = [
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
'Path=/identity/connect',
'HttpOnly',
'SameSite=Strict',
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
];
if (isHttps) parts.push('Secure');
return parts.join('; ');
}
function buildClearedRefreshCookie(request: Request): string {
return buildRefreshCookie(request, '', 0);
}
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
const headers = new Headers(response.headers);
headers.append(
'Set-Cookie',
refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
: buildClearedRefreshCookie(request)
);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function buildPreloginResponse(
email: string,
kdfType: number,
kdfIterations: number,
kdfMemory: number | null,
kdfParallelism: number | null
): Record<string, unknown> {
return {
kdf: kdfType,
kdfIterations,
kdfMemory,
kdfParallelism,
KdfSettings: {
KdfType: kdfType,
Iterations: kdfIterations,
Memory: kdfMemory,
Parallelism: kdfParallelism,
},
Salt: email.toLowerCase(),
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), String(TWO_FACTOR_PROVIDER_RECOVERY_CODE)]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
const customResponse = {
TwoFactorProviders: providers,
TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
};
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse(
{
error: 'invalid_grant',
error_description: message,
Error: 'invalid_grant',
ErrorDescription: message,
ErrorMessage: message,
TwoFactorProviders: customResponse.TwoFactorProviders,
TwoFactorProviders2: customResponse.TwoFactorProviders2,
// Required by current Android parser (nullable value is acceptable).
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
CustomResponse: customResponse,
ErrorModel: {
Message: message,
Object: 'error',
},
},
400
);
}
async function recordFailedLoginAndBuildResponse(
rateLimit: RateLimitService,
loginIdentifier: string,
message: string
): Promise<Response> {
const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
return identityErrorResponse(message, 'invalid_grant', 400);
}
async function recordFailedTwoFactorAndBuildResponse(
rateLimit: RateLimitService,
loginIdentifier: string
): Promise<Response> {
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
if (failed.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
}
// POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> {
@@ -25,12 +228,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 (grantType === 'password') {
// Login with password
const email = body.username?.toLowerCase();
const passwordHash = body.password;
const loginIdentifier = getClientIdentifier(request);
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.
@@ -52,114 +263,568 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
if (!valid) {
// Record failed login attempt
const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_password',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return recordFailedLoginAndBuildResponse(
rateLimit,
loginIdentifier,
'Username or password is incorrect. Try again'
);
}
// Optional 2FA: enabled only by per-user secret.
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
const hasProvider = normalizedTwoFactorProvider.length > 0;
const hasToken = normalizedTwoFactorToken.length > 0;
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
let passedByRememberToken = false;
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
if (deviceInfo.deviceIdentifier) {
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
normalizedTwoFactorToken,
deviceInfo.deviceIdentifier
);
passedByRememberToken = trustedUserId === user.id;
}
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
rememberRequested = false;
} else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
// Upstream behavior: do not issue a new remember token when auth itself used remember provider.
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
trustedTwoFactorTokenToReturn = createRefreshToken();
await storage.saveTrustedTwoFactorDeviceToken(
trustedTwoFactorTokenToReturn,
user.id,
deviceInfo.deviceIdentifier,
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
);
}
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful password + (optional) 2FA verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user);
const refreshToken = await auth.generateRefreshToken(user.id);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: refreshToken,
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: email, // email is already lowercased above
Object: 'masterPasswordUnlock',
},
},
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
return jsonResponse(response);
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier;
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
deviceResponse = JSON.parse(deviceResponse);
} catch {
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
}
}
if (!token || !deviceResponse) {
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
}
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
asserted = await assertAccountPasskeyCredential(request, env, storage, {
token,
deviceResponse,
scope: 'Authentication',
});
} catch (error) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: null,
action: 'auth.passkey.login.failed',
category: 'auth',
level: 'warn',
targetType: 'accountPasskey',
targetId: null,
metadata: {
grantType,
reason: error instanceof Error ? error.message : 'assertion_failed',
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
}
const { user, credential } = asserted;
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.passkey.login.success',
category: 'auth',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'client_credentials') {
// Login with client credentials
const clientId = body.client_id;
const clientSecret = body.client_secret;
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);
}
// Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const uid = clientId.slice(5);
const user = await storage.getUserById(uid);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.user_inactive',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.failed.bad_api_key',
category: 'auth',
level: 'warn',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
deviceIdentifier: deviceInfo.deviceIdentifier,
...auditRequestMetadata(request),
},
});
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful client credential verification.
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (!sendAccessLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
{
error: 'invalid_request',
error_description: 'send_id is required',
send_access_error_type: 'invalid_send_id',
ErrorModel: {
Message: 'send_id is required',
Object: 'error',
},
},
400
);
}
const passwordHashB64 = String(
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
).trim() || null;
const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(
env,
sendId,
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
);
if ('error' in result) {
return result.error;
}
return jsonResponse({
access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer',
scope: 'api.send',
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 = body.refresh_token;
const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE)
: null
);
if (!refreshToken) {
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
if (!result) {
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
const result = await auth.refreshAccessTokenDetailed(refreshToken);
if (!result.ok) {
await safeWriteAuditEvent(env, {
actorUserId: result.userId ?? null,
action: `auth.refresh.failed.${result.reason}`,
category: 'auth',
level: 'warn',
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
targetId: result.deviceIdentifier ?? null,
metadata: {
grantType,
reason: result.reason,
webSession: shouldUseWebSession(request),
...auditRequestMetadata(request),
},
});
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null)
: invalidResponse;
}
// Revoke old refresh token (prevent reuse)
await storage.deleteRefreshToken(refreshToken);
// 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 } = result;
const newRefreshToken = await auth.generateRefreshToken(user.id);
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);
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: newRefreshToken,
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
},
},
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
return jsonResponse(response);
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
: baseResponse;
}
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
@@ -186,13 +851,57 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
const kdfType = user?.kdfType ?? 0;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism;
// Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
// matching the response shape of real PBKDF2 users and reducing enumeration signal.
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({
kdf: kdfType,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism,
});
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
}
// POST /identity/connect/revocation
// Best-effort OAuth token revocation endpoint.
// 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 {
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return new Response(null, { status: 200 });
}
const token = String(body.token || '').trim() || (
shouldUseWebSession(request)
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
: ''
);
if (token) {
await storage.deleteRefreshToken(token);
}
const baseResponse = new Response(null, { status: 200 });
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null)
: baseResponse;
}
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
if (scope !== 'api') {
return false;
}
if (!clientId.startsWith('user.')) {
return false;
}
if (!clientSecret) {
return false;
}
return true;
}
+115 -56
View File
@@ -1,22 +1,32 @@
import { Env, Cipher, Folder, CipherType } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
ciphers: Array<{
id?: string | null;
type: number;
name: string;
name?: string | null;
notes?: string | null;
favorite?: boolean;
reprompt?: number;
sshKey?: any | null;
key?: string | null;
login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null;
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
username?: string | null;
password?: string | null;
totp?: string | null;
autofillOnPageLoad?: boolean | null;
uri?: string | null;
passwordRevisionDate?: string | null;
[key: string]: any;
} | null;
card?: {
cardholderName?: string | null;
@@ -57,6 +67,7 @@ interface CiphersImportRequest {
password: string;
lastUsedDate: string;
}> | null;
[key: string]: any;
}>;
folders: Array<{
name: string;
@@ -71,6 +82,16 @@ function bindNull(v: any): any {
return v === undefined ? null : v;
}
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
if (!source || typeof source !== 'object') return undefined;
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key] as T;
}
}
return undefined;
}
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);
@@ -81,6 +102,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
// POST /api/ciphers/import - Bitwarden client import endpoint
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
let importData: CiphersImportRequest;
try {
@@ -93,6 +116,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
}
const now = new Date().toISOString();
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
@@ -138,77 +165,100 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// 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) || null;
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || 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 fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
const cipher: Cipher = {
...c,
id: generateUUID(),
userId: userId,
type: c.type as CipherType,
folderId: folderId,
name: c.name || 'Untitled',
notes: c.notes || null,
favorite: c.favorite || false,
login: c.login ? {
username: c.login.username || null,
password: c.login.password || null,
uris: c.login.uris?.map(u => ({
uri: u.uri || null,
uriChecksum: null,
name: c.name ?? 'Untitled',
notes: c.notes ?? null,
favorite: c.favorite ?? false,
login: login ? {
...login,
username: login.username ?? null,
password: login.password ?? null,
uris: login.uris?.map((u: any) => ({
...u,
uri: u.uri ?? null,
uriChecksum: u.uriChecksum ?? null,
match: u.match ?? null,
})) || null,
totp: c.login.totp || null,
autofillOnPageLoad: null,
fido2Credentials: null,
uri: null,
passwordRevisionDate: null,
totp: login.totp ?? null,
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
uri: login.uri ?? null,
passwordRevisionDate: login.passwordRevisionDate ?? null,
} : null,
card: c.card ? {
cardholderName: c.card.cardholderName || null,
brand: c.card.brand || null,
number: c.card.number || null,
expMonth: c.card.expMonth || null,
expYear: c.card.expYear || null,
code: c.card.code || null,
card: card ? {
...card,
cardholderName: card.cardholderName ?? null,
brand: card.brand ?? null,
number: card.number ?? null,
expMonth: card.expMonth ?? null,
expYear: card.expYear ?? null,
code: card.code ?? null,
} : null,
identity: c.identity ? {
title: c.identity.title || null,
firstName: c.identity.firstName || null,
middleName: c.identity.middleName || null,
lastName: c.identity.lastName || null,
address1: c.identity.address1 || null,
address2: c.identity.address2 || null,
address3: c.identity.address3 || null,
city: c.identity.city || null,
state: c.identity.state || null,
postalCode: c.identity.postalCode || null,
country: c.identity.country || null,
company: c.identity.company || null,
email: c.identity.email || null,
phone: c.identity.phone || null,
ssn: c.identity.ssn || null,
username: c.identity.username || null,
passportNumber: c.identity.passportNumber || null,
licenseNumber: c.identity.licenseNumber || null,
identity: identity ? {
...identity,
title: identity.title ?? null,
firstName: identity.firstName ?? null,
middleName: identity.middleName ?? null,
lastName: identity.lastName ?? null,
address1: identity.address1 ?? null,
address2: identity.address2 ?? null,
address3: identity.address3 ?? null,
city: identity.city ?? null,
state: identity.state ?? null,
postalCode: identity.postalCode ?? null,
country: identity.country ?? null,
company: identity.company ?? null,
email: identity.email ?? null,
phone: identity.phone ?? null,
ssn: identity.ssn ?? null,
username: identity.username ?? null,
passportNumber: identity.passportNumber ?? null,
licenseNumber: identity.licenseNumber ?? null,
} : null,
secureNote: c.secureNote || null,
fields: c.fields?.map(f => ({
name: f.name || null,
value: f.value || null,
secureNote: secureNote ?? null,
fields: fields?.map((f: any) => ({
...f,
name: f.name ?? null,
value: f.value ?? null,
type: f.type,
linkedId: f.linkedId ?? null,
})) || null,
passwordHistory: c.passwordHistory || null,
reprompt: c.reprompt || 0,
sshKey: null,
key: null,
passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: key ?? null,
createdAt: now,
updatedAt: now,
archivedAt: null,
deletedAt: null,
};
cipher.login = normalizeCipherLoginForStorage(cipher.login);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) {
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
}
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
}
if (cipherRows.length > 0) {
@@ -216,10 +266,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const data = JSON.stringify(cipher);
return env.DB
.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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, deleted_at=excluded.deleted_at'
'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'
)
.bind(
cipher.id,
@@ -234,6 +284,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
bindNull(cipher.key),
cipher.createdAt,
cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt)
);
});
@@ -241,7 +292,15 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
}
// Update revision date
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
if (returnCipherMap) {
return jsonResponse({
object: 'import-result',
cipherMap: cipherMapRows,
});
}
return new Response(null, { status: 200 });
}
+58
View File
@@ -0,0 +1,58 @@
import { AuthService } from '../services/auth';
import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
function extractAccessToken(request: Request): string | null {
const url = new URL(request.url);
const queryToken = String(url.searchParams.get('access_token') || '').trim();
if (queryToken) return queryToken;
const authHeader = String(request.headers.get('Authorization') || '').trim();
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
const auth = new AuthService(env);
return auth.verifyAccessToken(`Bearer ${accessToken}`);
}
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
const connectionId = generateUUID();
return jsonResponse({
connectionId,
connectionToken: connectionId,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
});
}
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const userId = payload.sub;
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_uid', userId);
if (payload.did) {
forwardedUrl.searchParams.set('nw_did', payload.did);
}
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
+727
View File
@@ -0,0 +1,727 @@
import { Env, Send, SendAuthType, SendType } from '../types';
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 { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { LIMITS } from '../config/limits';
import {
getBlobStorageMaxBytes,
getSendFileObjectKey,
putBlobObject,
deleteBlobObject,
} from '../services/blob-store';
import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
import {
formatSize,
getAliasedProp,
normalizeEmails,
notifyVaultSyncForRequest,
parseDate,
parseFileLength,
parseInteger,
parseMaxAccessCount,
parseSendAuthType,
parseSendType,
parseStoredSendData,
sanitizeSendData,
sendToResponse,
setSendPassword,
validateDeletionDate,
} from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
async function writeSendAudit(
storage: StorageService,
request: Request,
userId: string,
action: string,
metadata: Record<string, unknown>
): Promise<void> {
await writeAuditEvent(storage, {
actorUserId: userId,
action,
category: 'data',
level: action.includes('delete') ? 'security' : 'info',
targetType: 'send',
targetId: typeof metadata.id === 'string' ? metadata.id : null,
metadata: {
...metadata,
...auditRequestMetadata(request),
},
});
}
async function processSendFileUpload(
request: Request,
env: Env,
send: Send,
fileId: string
): Promise<Response> {
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
const sendData = parseStoredSendData(send);
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse('Send file does not match send data.', 400);
}
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
const expectedSize = parseInteger(sendData.size);
const upload = await parseDirectUploadPayload(request, {
expectedSize,
expectedFileName,
maxFileSize,
tooLargeMessage: 'Send storage limit exceeded with this file',
sizeMismatchMessage: 'Send file size does not match.',
fileNameMismatchMessage: 'Send file name does not match.',
});
if (upload instanceof Response) {
return upload;
}
try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
sendId: send.id,
fileId,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('KV object too large')) {
return errorResponse('Send storage limit exceeded with this file', 413);
}
return errorResponse('Attachment storage is not configured', 500);
}
const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
return new Response(null, { status: 201 });
}
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const pagination = parsePagination(url);
let sends: Send[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
const hasNext = pageRows.length > pagination.limit;
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
} else {
sends = await storage.getAllSends(userId);
}
const sendResponses = sends.map(sendToResponse);
return jsonResponse({
data: sendResponses,
object: 'list',
continuationToken,
});
}
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);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
return jsonResponse(sendToResponse(send));
}
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
const sendType = parseSendType(typeRaw.value);
if (sendType === null) {
return errorResponse('Invalid Send type', 400);
}
if (sendType === SendType.File) {
return errorResponse('File sends should use /api/sends/file/v2', 400);
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
const keyRaw = getAliasedProp(body, ['key', 'Key']);
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
const textRaw = getAliasedProp(body, ['text', 'Text']);
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
const deletionDate = parseDate(deletionDateRaw.value);
if (!deletionDate) {
return errorResponse('Invalid deletionDate', 400);
}
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
const sendData = sanitizeSendData(textRaw.value);
if (!sendData) {
return errorResponse('Send data not provided', 400);
}
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
if (!maxAccess.ok) return maxAccess.response;
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
? null
: parseDate(expirationRaw.value);
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
return errorResponse('Invalid expirationDate', 400);
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
const now = new Date().toISOString();
const send: Send = {
id: generateUUID(),
userId,
type: sendType,
name: nameRaw.value.trim(),
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
data: JSON.stringify(sendData),
key: keyRaw.value,
passwordHash: null,
passwordSalt: null,
passwordIterations: null,
authType: requestedAuthType ?? SendAuthType.None,
emails: normalizedEmails,
maxAccessCount: maxAccess.value,
accessCount: 0,
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
createdAt: now,
updatedAt: now,
expirationDate: expirationDate ? expirationDate.toISOString() : null,
deletionDate: deletionDate.toISOString(),
};
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
await setSendPassword(send, passwordRaw.value);
} else if (send.authType === SendAuthType.Password) {
return errorResponse('Password is required for password auth', 400);
}
if (send.authType !== SendAuthType.Email) {
send.emails = null;
}
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
const sendType = parseSendType(typeRaw.value);
if (sendType !== SendType.File) {
return errorResponse('Send content is not a file', 400);
}
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
if (!fileLengthParsed.ok) return fileLengthParsed.response;
if (fileLengthParsed.value > maxFileSize) {
return errorResponse('Send storage limit exceeded with this file', 400);
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
const keyRaw = getAliasedProp(body, ['key', 'Key']);
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
const fileRaw = getAliasedProp(body, ['file', 'File']);
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
const deletionDate = parseDate(deletionDateRaw.value);
if (!deletionDate) {
return errorResponse('Invalid deletionDate', 400);
}
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
const fileData = sanitizeSendData(fileRaw.value);
if (!fileData) {
return errorResponse('Send data not provided', 400);
}
const fileId = generateUUID();
fileData.id = fileId;
fileData.size = fileLengthParsed.value;
fileData.sizeName = formatSize(fileLengthParsed.value);
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
if (!maxAccess.ok) return maxAccess.response;
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
? null
: parseDate(expirationRaw.value);
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
return errorResponse('Invalid expirationDate', 400);
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
const now = new Date().toISOString();
const send: Send = {
id: generateUUID(),
userId,
type: sendType,
name: nameRaw.value.trim(),
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
data: JSON.stringify(fileData),
key: keyRaw.value,
passwordHash: null,
passwordSalt: null,
passwordIterations: null,
authType: requestedAuthType ?? SendAuthType.None,
emails: normalizedEmails,
maxAccessCount: maxAccess.value,
accessCount: 0,
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
createdAt: now,
updatedAt: now,
expirationDate: expirationDate ? expirationDate.toISOString() : null,
deletionDate: deletionDate.toISOString(),
};
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
await setSendPassword(send, passwordRaw.value);
} else if (send.authType === SendAuthType.Password) {
return errorResponse('Password is required for password auth', 400);
}
if (send.authType !== SendAuthType.Email) {
send.emails = null;
}
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
sendResponse: sendToResponse(send),
});
}
export async function handleGetSendFileUpload(
request: Request,
env: Env,
userId: string,
sendId: string,
fileId: string
): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
const sendData = parseStoredSendData(send);
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse('Send file does not match send data.', 400);
}
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
return jsonResponse({
fileUploadType: 1,
object: 'send-fileUpload',
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
sendResponse: sendToResponse(send),
});
}
export async function handleUploadSendFile(
request: Request,
env: Env,
userId: string,
sendId: string,
fileId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
return processSendFileUpload(request, env, send, fileId);
}
export async function handlePublicUploadSendFile(
request: Request,
env: Env,
sendId: string,
fileId: string
): Promise<Response> {
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
}
const token = new URL(request.url).searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const claims = await verifySendFileUploadToken(token, jwtSecret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.sendId !== sendId || claims.fileId !== fileId) {
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404);
}
if (send.type !== SendType.File) {
return errorResponse('Send is not a file type send.', 400);
}
return processSendFileUpload(request, env, send, fileId);
}
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);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
let body: unknown;
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const typeRaw = getAliasedProp(body, ['type', 'Type']);
if (typeRaw.present) {
const incomingType = parseSendType(typeRaw.value);
if (incomingType === null) {
return errorResponse('Invalid Send type', 400);
}
if (incomingType !== send.type) {
return errorResponse("Sends can't change type", 400);
}
}
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
if (deletionRaw.present) {
const deletionDate = parseDate(deletionRaw.value);
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
const deletionValidation = validateDeletionDate(deletionDate);
if (deletionValidation) return deletionValidation;
send.deletionDate = deletionDate.toISOString();
}
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
if (expirationRaw.present) {
if (expirationRaw.value === null || expirationRaw.value === '') {
send.expirationDate = null;
} else {
const expiration = parseDate(expirationRaw.value);
if (!expiration) return errorResponse('Invalid expirationDate', 400);
send.expirationDate = expiration.toISOString();
}
}
const nameRaw = getAliasedProp(body, ['name', 'Name']);
if (nameRaw.present) {
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
return errorResponse('Name is required', 400);
}
send.name = nameRaw.value.trim();
}
const keyRaw = getAliasedProp(body, ['key', 'Key']);
if (keyRaw.present) {
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
return errorResponse('Key is required', 400);
}
send.key = keyRaw.value;
}
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
if (notesRaw.present) {
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
}
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
if (disabledRaw.present) {
if (typeof disabledRaw.value !== 'boolean') {
return errorResponse('Invalid disabled', 400);
}
send.disabled = disabledRaw.value;
}
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
if (hideEmailRaw.present) {
if (hideEmailRaw.value === null) {
send.hideEmail = null;
} else if (typeof hideEmailRaw.value === 'boolean') {
send.hideEmail = hideEmailRaw.value;
} else {
return errorResponse('Invalid hideEmail', 400);
}
}
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
if (maxAccessRaw.present) {
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
if (!parsedMax.ok) return parsedMax.response;
send.maxAccessCount = parsedMax.value;
}
if (send.type === SendType.Text) {
const textRaw = getAliasedProp(body, ['text', 'Text']);
if (textRaw.present) {
const textData = sanitizeSendData(textRaw.value);
if (!textData) {
return errorResponse('Send data not provided', 400);
}
send.data = JSON.stringify(textData);
}
}
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
if (authTypeRaw.present) {
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
send.authType = parsedAuthType;
if (parsedAuthType !== SendAuthType.Email) {
send.emails = null;
}
}
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
if (emailsRaw.present) {
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
send.emails = normalizedEmails;
if (send.emails) {
send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) {
send.authType = SendAuthType.None;
}
}
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
await setSendPassword(send, passwordRaw.value);
}
if (send.authType === SendAuthType.Password && !send.passwordHash) {
return errorResponse('Password is required for password auth', 400);
}
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
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);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
if (send.type === SendType.File) {
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
});
return new Response(null, { status: 200 });
}
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: string[] };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!body.ids || !Array.isArray(body.ids)) {
return errorResponse('ids array is required', 400);
}
const sends = await storage.getSendsByIds(body.ids, userId);
for (const send of sends) {
if (send.type !== SendType.File) continue;
const data = parseStoredSendData(send);
const fileId = typeof data.id === 'string' ? data.id : null;
if (fileId) {
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
}
}
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length,
requestedCount: body.ids.length,
});
}
return new Response(null, { status: 200 });
}
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
await setSendPassword(send, null);
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404);
}
send.authType = SendAuthType.None;
send.emails = null;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
});
return jsonResponse(sendToResponse(send));
}
+400
View File
@@ -0,0 +1,400 @@
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 {
createSendAccessToken,
createSendFileDownloadToken,
verifySendAccessToken,
verifySendFileDownloadToken,
} from '../utils/jwt';
import {
getBlobObject,
getSendFileObjectKey,
} from '../services/blob-store';
import {
SEND_INACCESSIBLE_MSG,
extractBearerToken,
fromAccessId,
getCreatorIdentifier,
getSafeJwtSecret,
hasEmailAuth,
isSendAvailable,
notifyVaultSyncForRequest,
parseStoredSendData,
resolveSendFromIdOrAccessId,
sendPasswordLimitKey,
sendPasswordLockedErrorResponse,
sendPasswordLockedOAuthResponse,
sendToAccessResponse,
validatePublicSendAccess,
verifySendPassword,
verifySendPasswordHashB64,
} from './sends-shared';
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId);
if (!sendId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const send = await storage.getSend(sendId);
if (!send || !isSendAvailable(send)) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
let body: unknown = {};
try {
body = await request.json();
} catch {
body = {};
}
let sendPasswordLimitIpKey: string | null = null;
let sendPasswordRateLimit: RateLimitService | null = null;
if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
}
export async function handleAccessSendFile(
request: Request,
env: Env,
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 storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
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);
}
let body: unknown = {};
try {
body = await request.json();
} catch {
body = {};
}
let sendPasswordLimitIpKey: string | null = null;
let sendPasswordRateLimit: RateLimitService | null = null;
if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
return jsonResponse({
object: 'send-fileDownload',
id: fileId,
url: downloadUrl,
});
}
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const token = extractBearerToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
const claims = await verifySendAccessToken(token, jwt.secret);
if (!claims) {
return errorResponse('Unauthorized', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(claims.sub);
if (!send || !isSendAvailable(send)) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
}
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const token = extractBearerToken(request);
if (!token) {
return errorResponse('Unauthorized', 401);
}
const claims = await verifySendAccessToken(token, jwt.secret);
if (!claims) {
return errorResponse('Unauthorized', 401);
}
const storage = new StorageService(env.DB);
const send = await storage.getSend(claims.sub);
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 updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url);
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
return jsonResponse({
object: 'send-fileDownload',
id: fileId,
url: downloadUrl,
});
}
export async function handleDownloadSendFile(
request: Request,
env: Env,
sendId: string,
fileId: string
): Promise<Response> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) return jwt.response;
const url = new URL(request.url);
const token = url.searchParams.get('t') || url.searchParams.get('token');
if (!token) {
return errorResponse('Token required', 401);
}
const claims = await verifySendFileDownloadToken(token, jwt.secret);
if (!claims) {
return errorResponse('Invalid or expired token', 401);
}
if (claims.sendId !== sendId || claims.fileId !== fileId) {
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.DB);
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size),
'Cache-Control': 'private, no-cache',
},
});
}
export async function issueSendAccessToken(
env: Env,
sendIdOrAccessId: string,
passwordHashB64?: string | null,
password?: string | null,
rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string
): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) {
return { error: jwt.response };
}
const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
if (!send || !isSendAvailable(send)) {
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: SEND_INACCESSIBLE_MSG,
send_access_error_type: 'send_not_available',
ErrorModel: {
Message: SEND_INACCESSIBLE_MSG,
Object: 'error',
},
},
400
),
};
}
if (hasEmailAuth(send)) {
const message = 'Email verification for this Send is not supported by this server.';
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: message,
send_access_error_type: 'email_verification_not_supported',
ErrorModel: {
Message: message,
Object: 'error',
},
},
400
),
};
}
if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return {
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
};
}
}
let ok = false;
if (passwordHashB64) {
ok = verifySendPasswordHashB64(send, passwordHashB64);
} else if (password) {
ok = await verifySendPassword(send, password);
}
if (!ok) {
if (rateLimit && sendPasswordLimitIpKey) {
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return {
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
};
}
}
return {
error: jsonResponse(
{
error: 'invalid_grant',
error_description: 'Invalid password.',
send_access_error_type: 'invalid_password',
ErrorModel: {
Message: 'Invalid password.',
Object: 'error',
},
},
400
),
};
}
if (rateLimit && sendPasswordLimitIpKey) {
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
}
const token = await createSendAccessToken(send.id, jwt.secret);
return { token };
}
+451
View File
@@ -0,0 +1,451 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { LIMITS } from '../config/limits';
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
const SEND_PASSWORD_ITERATIONS = 100_000;
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
export function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const value = (source as Record<string, unknown>)[key];
return { present: true, value };
}
}
return { present: false, value: undefined };
}
export function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function base64UrlDecode(input: string): Uint8Array | null {
try {
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
while (normalized.length % 4) normalized += '=';
const raw = atob(normalized);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
} catch {
return null;
}
}
function uuidToBytes(uuid: string): Uint8Array | null {
const hex = uuid.replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
function bytesToUuid(bytes: Uint8Array): string | null {
if (bytes.length !== 16) return null;
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
function toAccessId(sendId: string): string {
const bytes = uuidToBytes(sendId);
if (!bytes) return '';
return base64UrlEncode(bytes);
}
export function fromAccessId(accessId: string): string | null {
const bytes = base64UrlDecode(accessId);
if (!bytes || bytes.length !== 16) return null;
return bytesToUuid(bytes);
}
function isLikelyUuid(value: string): boolean {
return /^[a-f0-9-]{36}$/i.test(value);
}
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
if (isLikelyUuid(idOrAccessId)) {
const send = await storage.getSend(idOrAccessId);
if (send) return send;
}
const sendId = fromAccessId(idOrAccessId);
if (!sendId) return null;
return storage.getSend(sendId);
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function parseDate(raw: unknown): Date | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return null;
return date;
}
export function parseInteger(raw: unknown): number | null {
if (raw === null || raw === undefined || raw === '') return null;
const value = typeof raw === 'string' ? Number(raw) : raw;
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
return value;
}
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const data = { ...(raw as Record<string, unknown>) };
delete data.response;
return data;
}
export function parseStoredSendData(send: Send): Record<string, unknown> {
try {
const parsed = JSON.parse(send.data) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { ...(parsed as Record<string, unknown>) };
}
return {};
} catch {
return {};
}
}
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...data };
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
normalized.size = String(Math.trunc(normalized.size));
}
return normalized;
}
export function isSendAvailable(send: Send): boolean {
const now = Date.now();
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
return false;
}
if (send.expirationDate) {
const expirationMs = new Date(send.expirationDate).getTime();
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
return false;
}
}
const deletionMs = new Date(send.deletionDate).getTime();
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
return false;
}
if (send.disabled) {
return false;
}
return true;
}
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256',
},
key,
256
);
return new Uint8Array(bits);
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
function isLikelyHashB64(value: string): boolean {
const raw = String(value || '').trim();
if (!raw) return false;
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
const decoded = base64UrlDecode(raw);
return !!decoded && decoded.length === 32;
}
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
if (!password) {
send.passwordHash = null;
send.passwordSalt = null;
send.passwordIterations = null;
if (send.authType === SendAuthType.Password) {
send.authType = SendAuthType.None;
}
return;
}
if (isLikelyHashB64(password)) {
send.passwordHash = password.trim();
send.passwordSalt = null;
send.passwordIterations = null;
send.authType = SendAuthType.Password;
return;
}
const salt = crypto.getRandomValues(new Uint8Array(64));
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
send.passwordSalt = base64UrlEncode(salt);
send.passwordHash = base64UrlEncode(hash);
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
send.authType = SendAuthType.Password;
}
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
if (!send.passwordHash) {
return false;
}
if (!send.passwordSalt || !send.passwordIterations) {
return verifySendPasswordHashB64(send, password);
}
const salt = base64UrlDecode(send.passwordSalt);
const expected = base64UrlDecode(send.passwordHash);
if (!salt || !expected) return false;
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
return constantTimeEqual(actual, expected);
}
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
if (!send.passwordHash || !passwordHashB64) return false;
const expected = base64UrlDecode(send.passwordHash);
const provided = base64UrlDecode(passwordHashB64);
if (!expected || !provided) return false;
return constantTimeEqual(expected, provided);
}
export function validateDeletionDate(date: Date): Response | null {
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
if (date.getTime() > maxMs) {
return errorResponse(
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
400
);
}
return null;
}
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
const parsed = parseInteger(value);
if (value === undefined || value === null || value === '') {
return { ok: true, value: null };
}
if (parsed === null || parsed < 0) {
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
}
return { ok: true, value: parsed };
}
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
const parsed = parseInteger(value);
if (parsed === null) {
return { ok: false, response: errorResponse('Invalid send length', 400) };
}
if (parsed < 0) {
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
}
return { ok: true, value: parsed };
}
export function parseSendType(value: unknown): SendType | null {
const type = parseInteger(value);
if (type === SendType.Text || type === SendType.File) return type;
return null;
}
export function parseSendAuthType(value: unknown): SendAuthType | null {
if (value === undefined || value === null || value === '') return null;
const parsed = parseInteger(value);
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
return parsed;
}
return null;
}
export function normalizeEmails(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
if (strings.length === 0) return null;
return strings.join(',');
}
return null;
}
export function hasEmailAuth(send: Send): boolean {
return send.authType === SendAuthType.Email;
}
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) {
return { ok: false, response: errorResponse('Server configuration error', 500) };
}
return { ok: true, secret };
}
export function extractBearerToken(request: Request): string | null {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1].trim() : null;
}
export function sendToResponse(send: Send): SendResponse {
const data = normalizeSendDataSizeField(parseStoredSendData(send));
return {
id: send.id,
accessId: toAccessId(send.id),
type: Number(send.type) || 0,
name: send.name,
notes: send.notes,
text: send.type === SendType.Text ? data : null,
file: send.type === SendType.File ? data : null,
key: send.key,
maxAccessCount: send.maxAccessCount,
accessCount: send.accessCount,
password: send.passwordHash,
emails: send.emails,
authType: send.authType,
disabled: send.disabled,
hideEmail: send.hideEmail,
revisionDate: send.updatedAt,
expirationDate: send.expirationDate,
deletionDate: send.deletionDate,
object: 'send',
};
}
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
const data = normalizeSendDataSizeField(parseStoredSendData(send));
return {
id: send.id,
type: Number(send.type) || 0,
name: send.name,
text: send.type === SendType.Text ? data : null,
file: send.type === SendType.File ? data : null,
expirationDate: send.expirationDate,
deletionDate: send.deletionDate,
creatorIdentifier,
object: 'send-access',
};
}
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
if (send.hideEmail) return null;
const owner = await storage.getUserById(send.userId);
return owner?.email ?? null;
}
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}`;
}
function sendPasswordLockMessage(retryAfterSeconds: number): string {
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
}
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
}
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
const message = sendPasswordLockMessage(retryAfterSeconds);
return jsonResponse(
{
error: 'invalid_grant',
error_description: message,
send_access_error_type: 'too_many_password_attempts',
ErrorModel: {
Message: message,
Object: 'error',
},
},
429
);
}
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' };
}
if (!send.passwordHash) return { ok: true };
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const passwordHashB64Raw = getAliasedProp(body, [
'password_hash_b64',
'passwordHashB64',
'passwordHash',
'password_hash',
]);
let validPassword = false;
if (send.passwordSalt && send.passwordIterations) {
if (typeof passwordRaw.value !== 'string') {
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
}
validPassword = await verifySendPassword(send, passwordRaw.value);
} else {
const candidate =
typeof passwordHashB64Raw.value === 'string'
? passwordHashB64Raw.value
: typeof passwordRaw.value === 'string'
? passwordRaw.value
: '';
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
validPassword = verifySendPasswordHashB64(send, candidate);
}
if (!validPassword) {
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
}
return { ok: true };
}
+3
View File
@@ -0,0 +1,3 @@
export * from './sends-shared';
export * from './sends-private';
export * from './sends-public';
-57
View File
@@ -1,57 +0,0 @@
import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
import { renderRegisterPageHTML } from '../setup/pageTemplate';
import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
function getJwtSecretState(env: Env): JwtSecretState | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
// 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
const jwtState = getJwtSecretState(env);
return handleRegisterPage(request, env, jwtState);
}
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
const disabled = await storage.isSetupDisabled();
return jsonResponse({ registered, disabled });
}
// POST /setup/disable
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
if (!registered) {
return errorResponse('Registration required', 403);
}
await storage.setSetupDisabled();
return jsonResponse({ success: true });
}
+109 -94
View File
@@ -1,39 +1,47 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import {
buildAccountKeys,
buildUserDecryptionCompat,
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
interface SyncCacheEntry {
body: string;
expiresAt: number;
// CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
// Filtering invalid cipher responses here protects clients from stored rows that
// would otherwise make official apps fail after an HTTP 200 sync.
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
function buildSyncCacheRequest(
request: Request,
userId: string,
revisionDate: string,
accountPasskeyCacheTag: string,
excludeDomains: boolean,
excludeSends: boolean,
preserveRepairableUris: boolean
): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
}
const syncResponseCache = new Map<string, SyncCacheEntry>();
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
}
function readSyncCache(key: string): string | null {
const hit = syncResponseCache.get(key);
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
const hit = await caches.default.match(cacheRequest);
if (!hit) return null;
if (hit.expiresAt <= Date.now()) {
syncResponseCache.delete(key);
return null;
}
return hit.body;
return new Response(hit.body, hit);
}
function writeSyncCache(key: string, body: string): void {
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
if (oldestKey) syncResponseCache.delete(oldestKey);
}
syncResponseCache.set(key, {
body,
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
});
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
await caches.default.put(cacheRequest, response.clone());
}
// GET /api/sync
@@ -42,27 +50,46 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
const cachedBody = readSyncCache(cacheKey);
if (cachedBody) {
return new Response(cachedBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
const [revisionDate, accountPasskeys] = await Promise.all([
storage.getRevisionDate(userId),
storage.getAccountPasskeyCredentialsByUserId(userId),
]);
const accountPasskeyCacheTag = accountPasskeys
.map((credential) => [
credential.id,
credential.updatedAt,
credential.supportsPrf ? '1' : '0',
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
].join(':'))
.join(',');
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
}
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
// Build profile response
const profile: ProfileResponse = {
id: user.id,
name: user.name,
@@ -71,12 +98,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: false,
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -84,77 +111,65 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
// Build cipher responses with attachments
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments));
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris });
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
}
// Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
object: 'folder',
}));
const folderResponses: FolderResponse[] = [];
for (const folder of folders) {
folderResponses.push({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
});
}
const sendResponses = sends.map(sendToResponse);
const syncResponse: SyncResponse = {
profile: profile,
profile,
folders: folderResponses,
collections: [],
ciphers: cipherResponses,
domains: excludeDomains
? null
: {
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
},
: buildDomainsResponse(
domainSettings?.equivalentDomains || [],
domainSettings?.customEquivalentDomains || [],
domainSettings?.excludedGlobalEquivalentDomains || [],
{ omitExcludedGlobals: true }
),
policies: [],
sends: [],
// PascalCase for desktop/browser clients
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
},
},
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory || null,
parallelism: user.kdfParallelism || null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email.toLowerCase(),
},
sends: sendResponses,
UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions,
Object: 'userDecryption',
},
UserDecryptionOptions: userDecryptionOptions,
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
object: 'sync',
};
const body = JSON.stringify(syncResponse);
writeSyncCache(cacheKey, body);
return new Response(body, {
const response = new Response(JSON.stringify(syncResponse), {
status: 200,
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
},
});
await writeSyncCache(cacheRequest, response);
return response;
}
+105 -18
View File
@@ -1,44 +1,131 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { BackupTransferRunner } from './durable/backup-transfer-runner';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue } from './handlers/backup';
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
// initializeDatabase() only validates schema presence, so retries are cheap.
let dbInitialized = false;
let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null;
function normalizeRequestUrl(request: Request): Request {
const url = new URL(request.url);
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
if (normalizedPathname === url.pathname) return request;
url.pathname = normalizedPathname;
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();
const shouldNoIndex =
url.pathname === '/robots.txt' ||
contentType.includes('text/html');
if (!shouldNoIndex) return response;
const headers = new Headers(response.headers);
headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null;
const response = await env.ASSETS.fetch(request);
return addSearchIndexHeaders(request, response);
}
async function ensureDatabaseInitialized(env: Env): Promise<void> {
if (dbInitialized) return;
if (!dbInitPromise) {
dbInitPromise = (async () => {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
dbInitError = null;
})()
.catch((error: unknown) => {
console.error('Failed to initialize database:', error);
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
})
.finally(() => {
dbInitPromise = null;
});
}
await dbInitPromise;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Auto-initialize database on first request
if (!dbInitialized) {
try {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
dbInitError = null;
} catch (error) {
console.error('Failed to initialize database:', error);
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
}
void ctx;
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
}
await ensureDatabaseInitialized(env);
if (dbInitError) {
// Log full error server-side, return generic message to client.
console.error('DB init error (not forwarded to client):', dbInitError);
const resp = jsonResponse(
{
error: 'Database not initialized',
error_description: dbInitError,
error_description: 'Database initialization failed. Check server logs for details.',
ErrorModel: {
Message: dbInitError,
Message: 'Service temporarily unavailable',
Object: 'error',
},
},
500
);
return applyCors(request, resp);
return applyCors(normalizedRequest, resp);
}
const resp = await handleRequest(request, env);
return applyCors(request, resp);
const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp);
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
void controller;
await ensureDatabaseInitialized(env);
if (dbInitError) {
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
return;
}
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
console.error('Scheduled backup failed:', error);
}));
},
};
export { NotificationsHub };
export { BackupTransferRunner };
+74
View File
@@ -0,0 +1,74 @@
import type { Env, User } from './types';
import {
handleAdminExportBackup,
handleDownloadAdminRemoteBackup,
handleDeleteAdminRemoteBackup,
handleDownloadAdminBackupAttachment,
handleGetAdminBackupSettings,
handleGetAdminBackupSettingsRepairState,
handleInspectAdminRemoteBackup,
handleAdminImportBackup,
handleListAdminRemoteBackups,
handleRepairAdminBackupSettings,
handleRestoreAdminRemoteBackup,
handleRunAdminConfiguredBackup,
handleUpdateAdminBackupSettings,
} from './handlers/backup';
export async function handleAdminBackupRoute(
request: Request,
env: Env,
actorUser: User,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/admin/backup/export' && method === 'POST') {
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
if (path === '/api/admin/backup/settings') {
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser);
return null;
}
if (path === '/api/admin/backup/settings/repair') {
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, actorUser);
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, actorUser);
return null;
}
if (path === '/api/admin/backup/run' && method === 'POST') {
return handleRunAdminConfiguredBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote' && method === 'GET') {
return handleListAdminRemoteBackups(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
return handleDeleteAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
return handleRestoreAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/import' && method === 'POST') {
return handleAdminImportBackup(request, env, actorUser);
}
return null;
}
+69
View File
@@ -0,0 +1,69 @@
import type { Env, User } from './types';
import {
handleAdminListUsers,
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
handleAdminListAuditLogs,
handleAdminGetAuditLogSettings,
handleAdminUpdateAuditLogSettings,
handleAdminClearAuditLogs,
} from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup';
export async function handleAdminRoute(
request: Request,
env: Env,
actorUser: User,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'GET') {
return handleAdminListAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs' && method === 'DELETE') {
return handleAdminClearAuditLogs(request, env, actorUser);
}
if (path === '/api/admin/logs/settings') {
if (method === 'GET') return handleAdminGetAuditLogSettings(request, env, actorUser);
if (method === 'PUT' || method === 'POST') return handleAdminUpdateAuditLogSettings(request, env, actorUser);
return null;
}
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
if (adminBackupResponse) return adminBackupResponse;
if (path === '/api/admin/invites') {
if (method === 'GET') return handleAdminListInvites(request, env, actorUser);
if (method === 'POST') return handleAdminCreateInvite(request, env, actorUser);
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, actorUser);
return null;
}
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
}
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
return handleAdminSetUserStatus(request, env, actorUser, adminUserStatusMatch[1]);
}
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
if (adminUserDeleteMatch && method === 'DELETE') {
return handleAdminDeleteUser(request, env, actorUser, adminUserDeleteMatch[1]);
}
return null;
}
+367
View File
@@ -0,0 +1,367 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator,
handleDisableTwoFactorProvider,
handleGetApiKey,
handleRotateApiKey,
} from './handlers/accounts';
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handleDeleteCipherCompat,
handlePermanentDeleteCipher,
handleRestoreCipher,
handleBulkArchiveCiphers,
handlePartialUpdateCipher,
handleBulkUnarchiveCiphers,
handleBulkMoveCiphers,
handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers,
handleArchiveCipher,
handleUnarchiveCipher,
} from './handlers/ciphers';
import {
handleGetFolders,
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder,
handleBulkDeleteFolders,
} from './handlers/folders';
import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleBulkDeleteSends,
handleRemoveSendPassword,
handleRemoveSendAuth,
} from './handlers/sends';
import { handleSync } from './handlers/sync';
import { handleCiphersImport } from './handlers/import';
import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleUpdateAttachmentMetadata,
handleDeleteAttachment,
} from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices';
import { handleAdminRoute } from './router-admin';
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import {
handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential,
handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions,
handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys';
export async function handleAuthenticatedRoute(
request: Request,
env: Env,
userId: string,
currentUser: User,
path: string,
method: string
): Promise<Response | null> {
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([
'/api/accounts/set-password',
'/api/accounts/delete',
'/api/accounts/delete-account',
'/api/accounts/delete-vault',
]);
if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented', 501);
}
}
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
return handleChangePassword(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
}
if (path === '/api/accounts/totp') {
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
return null;
}
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
return handleGetTotpRecoveryCode(request, env, userId);
}
if (path === '/api/two-factor') {
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
return handleGetTwoFactorAuthenticator(request, env, userId);
}
if (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId);
}
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
}
if (path === '/api/accounts/verify-password' && method === 'POST') {
return handleVerifyPassword(request, env, userId);
}
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
return handleSetVerifyDevices(request, env, userId);
}
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
return handleGetApiKey(request, env, userId);
}
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
return handleRotateApiKey(request, env, userId);
}
if (path === '/api/webauthn' || path === '/webauthn') {
if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId);
if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId);
if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') {
return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser);
}
if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') {
return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser);
}
const accountPasskeyDeleteMatch =
path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) ||
path.match(/^\/webauthn\/([^/]+)\/delete$/i);
if (accountPasskeyDeleteMatch && method === 'POST') {
return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
if (path.startsWith('/notifications/')) {
return errorResponse('Not found', 404);
}
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);
if (method === 'POST') return handleCreateCipher(request, env, userId);
return null;
}
if (path === '/api/ciphers/import' && method === 'POST') {
return handleCiphersImport(request, env, userId);
}
if (path === '/api/ciphers/delete' && method === 'POST') {
return handleBulkDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
return handleBulkPermanentDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/restore' && method === 'POST') {
return handleBulkRestoreCiphers(request, env, userId);
}
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
return handleBulkArchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
return handleBulkUnarchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
return handleBulkMoveCiphers(request, env, userId);
}
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
if (cipherMatch) {
const cipherId = cipherMatch[1];
const subPath = cipherMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
if (attachmentMatch) {
const attachmentId = attachmentMatch[1];
if (method === 'POST' || method === 'PUT') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
}
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
}
}
if (path === '/api/folders') {
if (method === 'GET') return handleGetFolders(request, env, userId);
if (method === 'POST') return handleCreateFolder(request, env, userId);
return null;
}
if (path === '/api/folders/delete' && method === 'POST') {
return handleBulkDeleteFolders(request, env, userId);
}
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
if (folderMatch) {
const folderId = folderMatch[1];
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/sends') {
if (method === 'GET') return handleGetSends(request, env, userId);
if (method === 'POST') return handleCreateSend(request, env, userId);
return null;
}
if (path === '/api/sends/file/v2' && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
if (path === '/api/sends/delete' && method === 'POST') {
return handleBulkDeleteSends(request, env, userId);
}
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
if (sendMatch) {
const sendId = sendMatch[1];
const subPath = sendMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
}
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendPassword(request, env, userId, sendId);
}
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendAuth(request, env, userId, sendId);
}
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
if (sendFileUploadMatch) {
const fileId = sendFileUploadMatch[1];
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
}
}
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
return null;
}
if (path === '/api/settings/domains' || path === '/settings/domains') {
if (method === 'GET') return handleGetDomains(env, userId);
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
return null;
}
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
if (adminResponse) return adminResponse;
return null;
}
+121
View File
@@ -0,0 +1,121 @@
import type { Env } from './types';
import {
handleGetAuthorizedDevices,
handleGetDevice,
handleGetDevices,
handleGetDeviceByIdentifier,
handleUpdateDeviceKeys,
handleUpdateDeviceTrust,
handleUntrustDevices,
handleRetrieveDeviceKeys,
handleDeactivateDevice,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleTrustDevicePermanently,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
} from './handlers/devices';
export async function handleAuthenticatedDeviceRoute(
request: Request,
env: Env,
userId: string,
path: string,
method: string
): Promise<Response | null> {
if (path === '/api/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null;
}
if (path === '/api/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null;
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i);
if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
}
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
}
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
}
if (path === '/api/devices/update-trust' && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId);
}
if (path === '/api/devices/untrust' && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
return null;
}
+481
View File
@@ -0,0 +1,481 @@
import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import {
handleAccessSend,
handleAccessSendFile,
handleAccessSendV2,
handleAccessSendFileV2,
handleDownloadSendFile,
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import {
handleRegister,
handleGetPasswordHint,
handleRecoverTwoFactor,
} from './handlers/accounts';
import { handlePublicDownloadAttachment } from './handlers/attachments';
import { handlePublicUploadAttachment } from './handlers/attachments';
import {
handleNotificationsHub,
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
export interface WebBootstrapResponse {
defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
registrationInviteRequired: boolean;
}
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
const origin = request.headers.get('Origin');
if (origin) {
return origin === targetOrigin;
}
const referer = request.headers.get('Referer');
if (referer) {
try {
return new URL(referer).origin === targetOrigin;
} catch {
return false;
}
}
return false;
}
function getDefaultWebsiteIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function handleNwFavicon(): Response {
return new Response(getDefaultWebsiteIconSvg(), {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
function handleMissingWebsiteIcon(): Response {
return new Response(null, {
status: 404,
headers: {
'Cache-Control': 'public, max-age=300',
},
});
}
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 {
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
} catch {
return null;
}
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try {
const parsed = new URL(`https://${decoded}`);
return parsed.hostname === decoded ? decoded : null;
} catch {
return null;
}
}
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
type IconSource = {
url: string;
rejectImage?: {
byteLength: number;
sha256: string;
};
headers?: HeadersInit;
};
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
try {
return await fetch(source.url, {
headers: source.headers,
redirect: 'follow',
signal: controller.signal,
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
} finally {
clearTimeout(timeout);
}
}
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
const upstreamSources: IconSource[] = [
{
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
headers: requestHeaders,
},
{
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
rejectImage: {
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
},
headers: requestHeaders,
},
];
for (const source of upstreamSources) {
try {
const resp = await fetchIconSource(source);
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (!bytes) continue;
if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
}
return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch {
continue;
}
}
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapResponse> {
const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason =
!secret
? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short'
: null;
const storage = new StorageService(env.DB);
const userCount = await storage.getUserCount();
return {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
};
}
export async function handlePublicRoute(
request: Request,
env: Env,
path: string,
method: string,
enforcePublicRateLimit: PublicRateLimiter
): Promise<Response | null> {
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(await buildWebBootstrapResponse(env));
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
if (publicAttachmentMatch && method === 'GET') {
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
}
const publicAttachmentUploadMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)\/attachment\/([a-f0-9-]+)$/i);
if (publicAttachmentUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
return handlePublicUploadAttachment(request, env, publicAttachmentUploadMatch[1], publicAttachmentUploadMatch[2]);
}
const publicSendUploadMatch = path.match(/^\/api\/sends\/([^/]+)\/file\/([^/]+)\/?$/i);
if (publicSendUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
return handlePublicUploadSendFile(request, env, publicSendUploadMatch[1], publicSendUploadMatch[2]);
}
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSend(request, env, sendAccessMatch[1]);
}
if (path === '/api/sends/access' && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
}
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
}
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
if (sendDownloadMatch && method === 'GET') {
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
}
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env);
}
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
return new Response(null, { status: 200 });
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleGetAccountPasskeyAssertionOptions(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return handleGetPasswordHint(request, env);
}
if ((path === '/config' || path === '/api/config') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin));
}
if (path === '/api/version' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
}
if (path === '/api/accounts/register' && method === 'POST') {
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) {
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return handleRegister(request, env);
}
if (path === '/notifications/hub/negotiate' && method === 'POST') {
return handleNotificationsNegotiate(request, env);
}
if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env);
}
return null;
}
+98 -508
View File
@@ -1,553 +1,143 @@
import { Env, DEFAULT_DEV_SECRET } from './types';
import { DEFAULT_DEV_SECRET, Env } from './types';
import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { handleCors, errorResponse } from './utils/response';
import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public';
// Identity handlers
import { handleToken, handlePrelogin } from './handlers/identity';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | '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;
}
// Account handlers
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
// Cipher handlers
import {
handleGetCiphers,
handleGetCipher,
handleCreateCipher,
handleUpdateCipher,
handleDeleteCipher,
handlePermanentDeleteCipher,
handleRestoreCipher,
handlePartialUpdateCipher,
handleBulkMoveCiphers,
} from './handlers/ciphers';
// Folder handlers
import {
handleGetFolders,
handleGetFolder,
handleCreateFolder,
handleUpdateFolder,
handleDeleteFolder
} from './handlers/folders';
// Sync handler
import { handleSync } from './handlers/sync';
// Setup handlers
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
// Import handler
import { handleCiphersImport } from './handlers/import';
// Attachment handlers
import {
handleCreateAttachment,
handleUploadAttachment,
handleGetAttachment,
handleDeleteAttachment,
handlePublicDownloadAttachment,
} from './handlers/attachments';
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
const origin = request.headers.get('Origin');
if (origin) {
return origin === targetOrigin;
if (method === 'POST') {
if (path === '/api/ciphers/import') return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
}
const referer = request.headers.get('Referer');
if (referer) {
try {
return new URL(referer).origin === targetOrigin;
} catch {
return false;
}
}
// Require browser-origin evidence for setup/register write operations.
return false;
}
function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
}
function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
},
});
}
function isValidIconHostname(hostname: string): boolean {
if (!hostname) return false;
if (hostname.length > 253) return false;
const normalized = hostname.toLowerCase().replace(/\.$/, '');
// Slightly relaxed domain validation:
// - keep strict label boundaries (no leading/trailing hyphen)
// - allow punycode TLD (e.g. xn--...)
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
if (domainPattern.test(normalized)) return true;
if (!ipv4Pattern.test(normalized)) return false;
const parts = normalized.split('.');
return parts.every(p => {
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255;
});
}
// Icons handler - proxy to Bitwarden's official icon service
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
try {
void env;
const normalizedHostname = hostname.toLowerCase();
if (!isValidIconHostname(normalizedHostname)) {
return new Response(null, { status: 204 });
}
const cache = caches.default;
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
// Use Bitwarden's official icon service
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
const resp = await fetch(iconUrl, {
headers: { 'User-Agent': 'NodeWarden/1.0' },
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
});
if (resp.ok) {
const body = await resp.arrayBuffer();
const iconResponse = new Response(body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
},
});
await cache.put(cacheKey, iconResponse.clone());
return iconResponse;
}
return new Response(null, { status: 204 });
} catch {
return new Response(null, { status: 204 });
}
}
export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicRateLimit(
category: string = 'public',
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
): Promise<Response | null> {
if (!clientId) {
return new Response(
JSON.stringify({
error: 'Forbidden',
error_description: 'Client IP is required',
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
);
}
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null;
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
// Handle CORS preflight
if (method === 'OPTIONS') {
return handleCors(request);
}
// Route matching
try {
// Setup page (root)
if (path === '/' && method === 'GET') {
return handleSetupPage(request, env);
}
// Setup status
if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env);
}
// Disable setup page (one-way)
if (path === '/setup/disable' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
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);
}
return handleDisableSetup(request, env);
}
// Browser/devtools probe endpoint
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
// Favicon
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
return handleNwFavicon();
}
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch) {
const hostname = iconMatch[1];
return handleGetIcon(request, env, hostname);
}
// Public attachment download (no auth header, uses token in query string)
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
if (publicAttachmentMatch && method === 'GET') {
const cipherId = publicAttachmentMatch[1];
const attachmentId = publicAttachmentMatch[2];
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
}
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
}
// Known device check (no auth required) - returns plain string "true" or "false"
if (path.startsWith('/api/devices/knowndevice')) {
return new Response('true', {
headers: {
'Content-Type': 'text/plain',
},
});
}
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') {
return handlePrelogin(request, env);
}
// Config endpoint (no auth required for basic config)
// Bitwarden clients call GET "/config" (relative to the API base URL).
// They also tolerate different casing, but their response models use PascalCase.
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
if (isConfigRequest) {
const origin = url.origin;
return jsonResponse({
// ── Version Strategy (Plan E) ──────────────────────────────────────
// Bitwarden clients use this version for backwards-compatibility feature gating.
// Confirmed version-gated features (from client source code):
// - Individual cipher key encryption: >= 2024.2.0
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
// (documented in Vaultwarden source comments)
// There is NO global minimum version that blocks all client functionality.
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
// When Vaultwarden bumps their version, update this value accordingly.
// Vaultwarden source: src/api/core/mod.rs → fn config()
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
sso: '',
},
// Feature flags control client behavior. Clients use server-provided values;
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
// Only enable flags for features we actually support.
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
featureStates: {
'duo-redirect': true,
'email-verification': true,
'unauth-ui-refresh': true,
},
object: 'config',
});
}
// Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') {
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
}
// Registration endpoint (no auth required, but only works once)
if (path === '/api/accounts/register' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
return handleRegister(request, env);
}
// If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
// All other API endpoints require authentication
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
const payload = await auth.verifyAccessToken(authHeader);
if (!payload) {
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) {
return errorResponse('Unauthorized', 401);
}
const { payload, user: currentUser } = verified;
const actingDeviceId = String(payload.did || '').trim();
if (actingDeviceId) {
const nextHeaders = new Headers(request.headers);
nextHeaders.set('X-NodeWarden-Acting-Device-Id', actingDeviceId);
request = new Request(request, { headers: nextHeaders });
}
const userId = payload.sub;
const clientId = getClientIdentifier(request);
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
// Dedicated read rate limiting for heavy sync endpoint.
if (path === '/api/sync' && method === 'GET') {
if (!isImportBypassRequest(request, path, method)) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
}
// API rate limiting only for write operations (keep reads frictionless)
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
if (authenticatedResponse) return authenticatedResponse;
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
}
// Block account operations that could change password or delete user
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([
'/api/accounts/password',
'/api/accounts/change-password',
'/api/accounts/set-password',
'/api/accounts/master-password',
'/api/accounts/delete',
'/api/accounts/delete-account',
'/api/accounts/delete-vault',
]);
if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented in single-user mode', 501);
}
}
// Account endpoints
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
}
// Revision date endpoint
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
}
// Verify password endpoint
if (path === '/api/accounts/verify-password' && method === 'POST') {
return handleVerifyPassword(request, env, userId);
}
// Sync endpoint
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
// Cipher endpoints
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);
if (method === 'POST') return handleCreateCipher(request, env, userId);
}
// Ciphers import endpoint (Bitwarden client format)
if (path === '/api/ciphers/import' && method === 'POST') {
return handleCiphersImport(request, env, userId);
}
// Bulk cipher operations (only move is allowed)
if (path === '/api/ciphers/move') {
if (method === 'POST' || method === 'PUT') {
return handleBulkMoveCiphers(request, env, userId);
}
}
// Match /api/ciphers/:id patterns
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
if (cipherMatch) {
const cipherId = cipherMatch[1];
const subPath = cipherMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
if (method === 'DELETE') return handleDeleteCipher(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'PUT') {
return handleDeleteCipher(request, env, userId, cipherId);
}
if (subPath === '/delete' && method === 'DELETE') {
return handlePermanentDeleteCipher(request, env, userId, cipherId);
}
if (subPath === '/restore' && method === 'PUT') {
return handleRestoreCipher(request, env, userId, cipherId);
}
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
return handlePartialUpdateCipher(request, env, userId, cipherId);
}
// Share endpoint - just return the cipher (single user mode)
if (subPath === '/share' && method === 'POST') {
return handleGetCipher(request, env, userId, cipherId);
}
if (subPath === '/details' && method === 'GET') {
return handleGetCipher(request, env, userId, cipherId);
}
// Attachment endpoints
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
if (subPath === '/attachment/v2' && method === 'POST') {
return handleCreateAttachment(request, env, userId, cipherId);
}
// Legacy attachment endpoint - also goes to v2 flow
if (subPath === '/attachment' && method === 'POST') {
return handleCreateAttachment(request, env, userId, cipherId);
}
// Match /api/ciphers/{id}/attachment/{attachmentId}
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
if (attachmentMatch) {
const attachmentId = attachmentMatch[1];
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
// DELETE via POST (legacy)
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') {
const attachmentId = attachmentDeleteMatch[1];
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
}
}
// Folder endpoints
if (path === '/api/folders') {
if (method === 'GET') return handleGetFolders(request, env, userId);
if (method === 'POST') return handleCreateFolder(request, env, userId);
}
// Match /api/folders/:id patterns
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
if (folderMatch) {
const folderId = folderMatch[1];
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
// Auth requests endpoint (stub - we don't support passwordless login)
if (path.startsWith('/api/auth-requests')) {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
// Collections endpoint (stub - no organization support)
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Organizations endpoint (stub - no organization support)
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Sends endpoint (stub - not implemented)
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Policies endpoint (stub - not implemented)
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
if (method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
}
// Settings domains endpoint (stub)
if (path === '/api/settings/domains') {
if (method === 'GET') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
if (method === 'PUT' || method === 'POST') {
return jsonResponse({
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
});
}
}
// Devices endpoint (stub) - for authenticated requests
if (path === '/api/devices' && method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null });
}
// Not found
return errorResponse('Not found', 404);
} catch (error) {
console.error('Request error:', error);
return errorResponse('Internal server error', 500);
+210
View File
@@ -0,0 +1,210 @@
import type { Env } from '../types';
import { generateUUID } from '../utils/uuid';
import { StorageService } from './storage';
export type AuditLogCategory = 'auth' | 'security' | 'device' | 'data' | 'system';
export type AuditLogLevel = 'info' | 'warn' | 'error' | 'security';
export interface AuditEventInput {
actorUserId?: string | null;
action: string;
category: AuditLogCategory;
level?: AuditLogLevel;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
const SENSITIVE_KEY_RE = /(token|secret|password|key|hash|code|private)/i;
const MAX_METADATA_BYTES = 2048;
const AUDIT_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
const AUDIT_CLEANUP_PROBABILITY = 0.02;
const AUDIT_LOG_SETTINGS_KEY = 'audit.logs.settings.v1';
const DEFAULT_AUDIT_LOG_SETTINGS: AuditLogSettings = {
retentionDays: 90,
maxEntries: null,
};
let lastAuditCleanupAt = 0;
export interface AuditLogSettings {
retentionDays: number | null;
maxEntries: number | null;
}
const ALLOWED_METADATA_KEYS = new Set([
'method',
'path',
'ip',
'userAgent',
'email',
'targetEmail',
'grantType',
'webSession',
'deviceIdentifier',
'deviceType',
'reason',
'status',
'verifyDevices',
'changed',
'removed',
'updated',
'deleted',
'removedTrusted',
'removedSessions',
'removedDevices',
'requested',
'count',
'requestedCount',
'type',
'folderId',
'cipherId',
'size',
'users',
'ciphers',
'attachments',
'skippedAttachments',
'skippedReason',
'replaceExisting',
'provider',
'prfStatus',
'fileName',
'fileBytes',
'bytes',
'compressedBytes',
'includesAttachments',
'destinationName',
'destinationId',
'destinationType',
'destinationCount',
'scheduledDestinationCount',
'retentionDays',
'maxEntries',
'remotePath',
'trigger',
'prunedFileCount',
'pruneError',
'uploadVerificationAttempts',
'error',
'expiresInHours',
'checksumMismatchAccepted',
]);
function normalizePositiveInteger(value: unknown, allowed: readonly number[]): number | null {
if (value === null || value === 0 || value === '0' || value === 'forever' || value === 'unlimited') return null;
const parsed = Math.floor(Number(value));
return allowed.includes(parsed) ? parsed : null;
}
export function normalizeAuditLogSettings(value: unknown): AuditLogSettings {
const input = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const retentionDays = normalizePositiveInteger(input.retentionDays, [7, 30, 90, 180, 365]);
const maxEntries = normalizePositiveInteger(input.maxEntries, [1_000, 5_000, 10_000, 50_000]);
if (retentionDays) return { retentionDays, maxEntries: null };
if (maxEntries) return { retentionDays: null, maxEntries };
if (input.retentionDays === null || input.retentionDays === 0 || input.retentionDays === '0') {
return { retentionDays: null, maxEntries: null };
}
if (input.maxEntries === null || input.maxEntries === 0 || input.maxEntries === '0') {
return { retentionDays: null, maxEntries: null };
}
return {
...DEFAULT_AUDIT_LOG_SETTINGS,
};
}
export function auditRequestMetadata(request: Request): Record<string, unknown> {
const url = new URL(request.url);
return {
method: request.method,
path: url.pathname,
ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null,
userAgent: request.headers.get('User-Agent') || null,
};
}
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(metadata)) {
if (!ALLOWED_METADATA_KEYS.has(key)) continue;
if (value === undefined || value === null || value === '') continue;
if (SENSITIVE_KEY_RE.test(key)) continue;
if (Array.isArray(value)) {
clean[key] = value.length;
continue;
}
if (typeof value === 'object') continue;
clean[key] = value;
}
return clean;
}
export async function getAuditLogSettings(storage: StorageService): Promise<AuditLogSettings> {
const raw = await storage.getConfigValue(AUDIT_LOG_SETTINGS_KEY);
if (!raw) return { ...DEFAULT_AUDIT_LOG_SETTINGS };
try {
return normalizeAuditLogSettings(JSON.parse(raw));
} catch {
return { ...DEFAULT_AUDIT_LOG_SETTINGS };
}
}
export async function saveAuditLogSettings(storage: StorageService, settings: AuditLogSettings): Promise<AuditLogSettings> {
const normalized = normalizeAuditLogSettings(settings);
await storage.setConfigValue(AUDIT_LOG_SETTINGS_KEY, JSON.stringify(normalized));
await applyAuditLogRetention(storage, normalized);
return normalized;
}
export async function applyAuditLogRetention(storage: StorageService, settings?: AuditLogSettings): Promise<void> {
const current = settings || await getAuditLogSettings(storage);
if (current.retentionDays) {
const before = new Date(Date.now() - current.retentionDays * 24 * 60 * 60 * 1000).toISOString();
await storage.pruneAuditLogs(before);
}
if (current.maxEntries) {
await storage.pruneAuditLogsToMax(current.maxEntries);
}
}
async function maybePruneAuditLogs(storage: StorageService): Promise<void> {
const now = Date.now();
if (now - lastAuditCleanupAt < AUDIT_CLEANUP_INTERVAL_MS) return;
if (Math.random() > AUDIT_CLEANUP_PROBABILITY) return;
lastAuditCleanupAt = now;
await applyAuditLogRetention(storage);
}
async function insertAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
const metadata = sanitizeMetadata(event.metadata || {});
let metadataJson = JSON.stringify(metadata);
if (new TextEncoder().encode(metadataJson).byteLength > MAX_METADATA_BYTES) {
metadataJson = JSON.stringify({ truncated: true });
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: event.actorUserId ?? null,
action: event.action,
category: event.category,
level: event.level || 'info',
targetType: event.targetType ?? null,
targetId: event.targetId ?? null,
metadata: metadataJson,
createdAt: new Date().toISOString(),
});
await maybePruneAuditLogs(storage);
}
export async function writeAuditEvent(storage: StorageService, event: AuditEventInput): Promise<void> {
try {
await insertAuditEvent(storage, event);
} catch (error) {
console.error('audit log write failed', error);
}
}
export async function safeWriteAuditEvent(env: Env, event: AuditEventInput): Promise<void> {
await writeAuditEvent(new StorageService(env.DB), event);
}
+226 -24
View File
@@ -2,48 +2,201 @@ import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { StorageService } from './storage';
// Server-side iterations for second-layer hashing.
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry {
user: User | null;
expiresAt: number;
}
interface CachedDeviceEntry {
device: Awaited<ReturnType<StorageService['getDevice']>>;
expiresAt: number;
}
export interface VerifiedAccessContext {
payload: JWTPayload;
user: User;
}
export type RefreshAccessTokenFailureReason =
| 'token_not_found_or_expired'
| 'user_missing'
| 'user_inactive'
| 'device_missing'
| 'device_session_mismatch';
export type RefreshAccessTokenResult =
| { ok: true; accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null }
| {
ok: false;
reason: RefreshAccessTokenFailureReason;
userId?: string | null;
deviceIdentifier?: string | null;
};
export class AuthService {
private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
private static deviceCache = new Map<string, CachedDeviceEntry>();
constructor(private env: Env) {
this.storage = new StorageService(env.DB);
}
// Verify password hash (compare with stored hash)
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
const input = new TextEncoder().encode(inputHash);
const stored = new TextEncoder().encode(storedHash);
if (input.length !== stored.length) return false;
static invalidateUserCache(userId: string): void {
const normalizedUserId = String(userId || '').trim();
if (!normalizedUserId) return;
AuthService.userCache.delete(normalizedUserId);
const prefix = `${normalizedUserId}:`;
for (const key of AuthService.deviceCache.keys()) {
if (key.startsWith(prefix)) {
AuthService.deviceCache.delete(key);
}
}
}
static invalidateDeviceCache(userId: string, deviceId: string): void {
const normalizedUserId = String(userId || '').trim();
const normalizedDeviceId = String(deviceId || '').trim();
if (!normalizedUserId || !normalizedDeviceId) return;
AuthService.deviceCache.delete(`${normalizedUserId}:${normalizedDeviceId}`);
}
private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId);
if (!cached) return undefined;
if (cached.expiresAt <= Date.now()) {
AuthService.userCache.delete(userId);
return undefined;
}
return cached.user;
}
private writeCachedUser(userId: string, user: User | null): void {
AuthService.userCache.set(userId, {
user,
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
});
}
private async getCachedUser(userId: string): Promise<User | null> {
const cached = this.readCachedUser(userId);
if (cached !== undefined) return cached;
const user = await this.storage.getUserById(userId);
this.writeCachedUser(userId, user);
return user;
}
private async getFreshUser(userId: string): Promise<User | null> {
const user = await this.storage.getUserById(userId);
this.writeCachedUser(userId, user);
return user;
}
private readCachedDevice(userId: string, deviceId: string) {
const cacheKey = `${userId}:${deviceId}`;
const cached = AuthService.deviceCache.get(cacheKey);
if (!cached) return undefined;
if (cached.expiresAt <= Date.now()) {
AuthService.deviceCache.delete(cacheKey);
return undefined;
}
return cached.device;
}
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
const cacheKey = `${userId}:${deviceId}`;
AuthService.deviceCache.set(cacheKey, {
device,
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
});
}
private async getCachedDevice(userId: string, deviceId: string) {
const cached = this.readCachedDevice(userId, deviceId);
if (cached !== undefined) return cached;
const device = await this.storage.getDevice(userId, deviceId);
this.writeCachedDevice(userId, deviceId, device);
return device;
}
private async getFreshDevice(userId: string, deviceId: string) {
const device = await this.storage.getDevice(userId, deviceId);
this.writeCachedDevice(userId, deviceId, device);
return device;
}
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(clientHash),
'PBKDF2',
false,
['deriveBits']
);
const salt = new TextEncoder().encode(email.toLowerCase().trim());
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
keyMaterial,
256
);
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return SERVER_HASH_PREFIX + btoa(binary);
}
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
return this.constantTimeEquals(inputHash, storedHash);
}
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
}
private 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 < input.length; i++) {
diff |= input[i] ^ stored[i];
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
// Generate access token
async generateAccessToken(user: User): Promise<string> {
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
return createJWT(
{
sub: user.id,
email: user.email,
name: user.name,
sstamp: user.securityStamp,
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
},
this.env.JWT_SECRET
);
}
// Generate refresh token
async generateRefreshToken(userId: string): Promise<string> {
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
const token = createRefreshToken();
await this.storage.saveRefreshToken(token, userId);
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
return token;
}
// Verify access token from Authorization header
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
async verifyAccessTokenWithUser(authHeader: string | null): Promise<VerifiedAccessContext | null> {
if (!authHeader) return null;
const parts = authHeader.split(' ');
@@ -54,26 +207,75 @@ export class AuthService {
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
if (!payload) return null;
// Verify security stamp - ensures token is invalidated after password change
const user = await this.storage.getUserById(payload.sub);
let user = await this.getCachedUser(payload.sub);
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
user = await this.getFreshUser(payload.sub);
}
if (!user) return null;
if (user.status !== 'active') return null;
if (payload.sstamp !== user.securityStamp) {
return null; // Token was issued before password change
return null;
}
return payload;
if (payload.did) {
let device = await this.getCachedDevice(user.id, payload.did);
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
device = await this.getFreshDevice(user.id, payload.did);
}
if (!device) return null;
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
}
return { payload, user };
}
// Verify access token from Authorization header
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
const verified = await this.verifyAccessTokenWithUser(authHeader);
return verified?.payload ?? null;
}
// Refresh access token
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
if (!userId) return null;
async refreshAccessTokenDetailed(refreshToken: string): Promise<RefreshAccessTokenResult> {
const record = await this.storage.getRefreshTokenRecord(refreshToken);
if (!record?.userId) return { ok: false, reason: 'token_not_found_or_expired' };
const user = await this.storage.getUserById(userId);
if (!user) return null;
const user = await this.storage.getUserById(record.userId);
if (!user) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_missing', userId: record.userId, deviceIdentifier: record.deviceIdentifier };
}
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'user_inactive', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
const accessToken = await this.generateAccessToken(user);
return { accessToken, user };
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 };
}
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device };
}
async refreshAccessToken(
refreshToken: string
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
const result = await this.refreshAccessTokenDetailed(refreshToken);
return result.ok ? result : null;
}
}
+500
View File
@@ -0,0 +1,500 @@
import { zipSync, unzipSync } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
import { exportPortableBackupSettingsEnvelope } from './backup-settings-crypto';
import {
getAttachmentObjectKey,
getBlobStorageKind,
} from './blob-store';
// CONTRACT:
// This file defines the exported instance-backup archive shape. Keep it in lock
// step with src/services/backup-import.ts and webapp/src/lib/api/backup.ts.
//
// WHEN CHANGING THIS:
// - Add persistent tables to BackupPayload, export SQL, manifest tableCounts,
// and validateBackupPayloadContents().
// - Keep secrets and transient runtime rows sanitized before writing db.json.
// - users.api_key is intentionally not exported.
// - backup.settings.v1 is exported as portable-only; the current server runtime
// envelope must not leave the instance.
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
const BACKUP_RUNNER_LOCK_CONFIG_KEY = 'backup.runner.lock.v1';
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Worker-side backup export must stay well below Cloudflare CPU limits.
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2;
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;
export interface BackupManifest {
formatVersion: 1;
exportedAt: string;
appVersion: string;
storageKind: 'r2' | 'kv' | null;
tableCounts: Record<string, number>;
includes: {
attachments: boolean;
};
blobSummary: {
attachmentFiles: number;
totalBytes: number;
largestObjectBytes: number;
};
attachmentBlobs?: BackupManifestAttachmentBlob[];
}
export interface BackupManifestAttachmentBlob {
cipherId: string;
attachmentId: string;
blobName: string;
sizeBytes: number;
}
export interface BackupPayload {
manifest: BackupManifest;
db: {
config: SqlRow[];
users: SqlRow[];
domain_settings: SqlRow[];
user_revisions: SqlRow[];
folders: SqlRow[];
ciphers: SqlRow[];
attachments: SqlRow[];
webauthn_credentials?: SqlRow[];
};
}
export interface BackupArchiveBundle {
bytes: Uint8Array;
fileName: string;
manifest: BackupManifest;
}
export interface BackupFileIntegrityCheckResult {
hasChecksumPrefix: boolean;
expectedPrefix: string | null;
actualPrefix: string;
matches: boolean;
}
export interface BuildBackupArchiveOptions {
includeAttachments?: boolean;
progress?: BackupArchiveBuildProgressReporter;
timeZone?: string;
}
export interface BackupArchiveBuildProgressEvent {
step: string;
fileName?: string;
stageTitle: string;
stageDetail: string;
includeAttachments: boolean;
}
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
return (result.results || []).map((row) => ({ ...row }));
}
function sanitizeConfigRowsForExport(rows: SqlRow[]): SqlRow[] {
const sanitized: SqlRow[] = [];
for (const row of rows) {
const key = String(row.key || '').trim();
if (!key || key === BACKUP_RUNNER_LOCK_CONFIG_KEY) continue;
if (key === BACKUP_SETTINGS_CONFIG_KEY) {
const portableOnly = exportPortableBackupSettingsEnvelope(typeof row.value === 'string' ? row.value : null);
if (portableOnly) sanitized.push({ ...row, value: portableOnly });
continue;
}
sanitized.push({ ...row });
}
return sanitized;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function getDateParts(date: Date, timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const parts = formatter.formatToParts(date);
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
}
function buildBackupFileNameInTimeZone(
date: Date = new Date(),
checksumPrefix: string | null = null,
timeZone: string = 'UTC'
): string {
const parts = getDateParts(date, timeZone);
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
return `nodewarden_backup_${parts}${suffix}.zip`;
}
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
const normalized = String(fileName || '').trim();
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
return match ? match[1].toLowerCase() : null;
}
export async function inspectBackupArchiveFileNameChecksum(
bytes: Uint8Array,
fileName: string
): Promise<BackupFileIntegrityCheckResult> {
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
const actualHash = await sha256Hex(bytes);
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
return {
hasChecksumPrefix: !!expectedPrefix,
expectedPrefix,
actualPrefix,
matches: !expectedPrefix || actualPrefix === expectedPrefix,
};
}
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
return result.matches;
}
function validateArchiveSize(bytes: Uint8Array): void {
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
}
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = [];
for (const row of db.attachments) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
}
return entries;
}
function ensureRowArray(value: unknown, table: string): SqlRow[] {
if (!Array.isArray(value)) {
throw new Error(`Backup archive table ${table} is invalid`);
}
return value as SqlRow[];
}
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)) {
entries[path] = [bytes, { level: BACKUP_TEXT_COMPRESSION_LEVEL }];
}
return entries;
}
export interface ParseBackupArchiveOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function parseBackupArchive(
bytes: Uint8Array,
options: ParseBackupArchiveOptions = {}
): { payload: BackupPayload; files: Record<string, Uint8Array> } {
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
try {
zipped = unzipSync(bytes);
} catch {
throw new Error('Invalid backup archive');
}
const entryNames = Object.keys(zipped);
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
let totalExtractedBytes = 0;
for (const entry of entryNames) {
const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
}
const manifestBytes = zipped['manifest.json'];
const dbBytes = zipped['db.json'];
if (!manifestBytes || !dbBytes) {
throw new Error('Backup archive is missing manifest.json or db.json');
}
const decoder = new TextDecoder();
let manifest: BackupManifest;
let db: BackupPayload['db'];
try {
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
} catch {
throw new Error('Backup archive contains invalid JSON metadata');
}
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 externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
: []
);
const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry));
for (const entry of requiredEntries) {
if (!zipped[entry]) {
throw new Error(`Backup archive is missing required file: ${entry}`);
}
}
return {
payload: { manifest, db },
files: zipped,
};
}
export interface ValidateBackupPayloadOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function validateBackupPayloadContents(
payload: BackupPayload,
files: Record<string, Uint8Array>,
options: ValidateBackupPayloadOptions = {}
): void {
const configRows = ensureRowArray(payload.db.config, 'config');
const userRows = ensureRowArray(payload.db.users, 'users');
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
const domainSettingsRows = ensureRowArray(payload.db.domain_settings || [], 'domain_settings');
const folderRows = ensureRowArray(payload.db.folders, 'folders');
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
: []
);
const userIds = new Set<string>();
for (const row of userRows) {
const id = String(row.id || '').trim();
const email = String(row.email || '').trim();
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
userIds.add(id);
}
for (const row of configRows) {
const key = String(row.key || '').trim();
if (!key) throw new Error('Backup archive contains an invalid config row');
}
for (const row of revisionRows) {
const userId = String(row.user_id || '').trim();
if (!userId || !userIds.has(userId)) {
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
}
}
const domainSettingUserIds = new Set<string>();
for (const row of domainSettingsRows) {
const userId = String(row.user_id || '').trim();
if (!userId || !userIds.has(userId)) {
throw new Error(`Backup archive contains domain settings for an unknown user: ${userId || '(empty)'}`);
}
if (domainSettingUserIds.has(userId)) {
throw new Error(`Backup archive contains duplicate domain settings for user: ${userId}`);
}
domainSettingUserIds.add(userId);
}
const folderIds = new Set<string>();
for (const row of folderRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
folderIds.add(id);
}
const cipherIds = new Set<string>();
for (const row of cipherRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const folderId = String(row.folder_id || '').trim();
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
if (folderId && !folderIds.has(folderId)) {
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
}
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
cipherIds.add(id);
}
for (const row of attachmentRows) {
const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row');
}
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) {
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
}
}
const accountPasskeyIds = new Set<string>();
const accountPasskeyCredentialIds = new Set<string>();
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
throw new Error('Backup archive contains an invalid account passkey row');
}
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`);
accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId);
}
}
export async function buildBackupArchive(
env: Env,
date: Date = new Date(),
options: BuildBackupArchiveOptions = {}
): Promise<BackupArchiveBundle> {
const includeAttachments = options.includeAttachments !== false;
await options.progress?.({
step: 'collect_data',
fileName: '',
stageTitle: 'txt_backup_archive_progress_collect_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_collect_with_attachments_detail'
: 'txt_backup_archive_progress_collect_detail',
includeAttachments,
});
const encoder = new TextEncoder();
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 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'),
]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
return {
cipherId,
attachmentId,
blobName: getAttachmentObjectKey(cipherId, attachmentId),
sizeBytes: Number(row.size || 0) || 0,
};
});
const manifestBase = {
formatVersion: BACKUP_FORMAT_VERSION,
exportedAt: date.toISOString(),
appVersion: APP_VERSION,
storageKind: getBlobStorageKind(env),
tableCounts: {
config: exportedConfigRows.length,
users: userRows.length,
domain_settings: domainSettingsRows.length,
user_revisions: revisionRows.length,
folders: folderRows.length,
ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length,
},
includes: {
attachments: includeAttachments,
},
blobSummary: {
attachmentFiles: attachmentBlobs.length,
totalBytes: attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0),
largestObjectBytes: attachmentBlobs.reduce((max, item) => Math.max(max, item.sizeBytes), 0),
},
attachmentBlobs: includeAttachments ? attachmentBlobs : [],
} satisfies BackupManifest;
const files: Record<string, Uint8Array> = {
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
'db.json': encoder.encode(JSON.stringify({
config: exportedConfigRows,
users: userRows,
domain_settings: domainSettingsRows,
user_revisions: revisionRows,
folders: folderRows,
ciphers: cipherRows,
attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows,
}, null, BACKUP_JSON_INDENT)),
};
await options.progress?.({
step: 'package_archive',
fileName: '',
stageTitle: 'txt_backup_archive_progress_package_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_package_with_attachments_detail'
: 'txt_backup_archive_progress_package_detail',
includeAttachments,
});
const bytes = zipSync(createZipEntries(files));
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
const backupTimeZone = options.timeZone || 'UTC';
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
await options.progress?.({
step: 'archive_ready',
fileName,
stageTitle: 'txt_backup_archive_progress_ready_title',
stageDetail: 'txt_backup_archive_progress_ready_detail',
includeAttachments,
});
return {
bytes,
fileName,
manifest: manifestBase,
};
}
+653
View File
@@ -0,0 +1,653 @@
import type { Env, User } from '../types';
import { StorageService } from './storage';
import {
type BackupSettingsPortableEnvelope,
decryptBackupSettingsRuntime,
encryptBackupSettingsEnvelope,
parseBackupSettingsEnvelope,
} from './backup-settings-crypto';
import {
BACKUP_DEFAULT_INTERVAL_HOURS,
BACKUP_DEFAULT_START_TIME,
BACKUP_DEFAULT_TIMEZONE,
type BackupDestinationConfig,
type BackupDestinationRecord,
type BackupDestinationType,
type BackupRuntimeState,
type BackupScheduleConfig,
type BackupSettings,
type S3BackupDestination,
type WebDavBackupDestination,
createBackupRandomId,
createDefaultBackupDestinationName,
createDefaultBackupScheduleConfig,
createDefaultBackupSettings as createSharedDefaultBackupSettings,
} from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24;
export type {
BackupDestinationConfig,
BackupDestinationRecord,
BackupDestinationType,
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings,
S3BackupDestination,
WebDavBackupDestination,
} from '../../shared/backup-schema';
export interface BackupSettingsInput {
destinations?: unknown;
}
export interface BackupSettingsRepairState {
needsRepair: boolean;
portable: BackupSettingsPortableEnvelope | null;
}
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return String(value ?? '').trim();
}
function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function assertValidTimeZone(timezone: string): string {
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
return timezone;
} catch {
throw new Error('Invalid backup timezone');
}
}
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
if (value === undefined) return fallback;
if (value === null || String(value).trim() === '') return null;
const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 1000) {
throw new Error('Backup retention count must be between 1 and 1000');
}
return count;
}
function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
throw new Error('Backup interval hours must be between 1 and 99');
}
return raw;
}
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
const raw = asTrimmedString(value) || fallback;
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
if (!match) {
throw new Error('Backup start time must be in HH:mm format');
}
const hour = Number(match[1]);
const minute = Number(match[2] ?? '0');
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new Error('Backup start time must be in HH:mm format');
}
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket);
const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto';
const rootPath = normalizePath(source.rootPath);
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://');
}
if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('S3 bucket is required');
}
if (!allowIncomplete || accessKeyId) {
if (!accessKeyId) throw new Error('S3 access key is required');
}
if (!allowIncomplete || secretAccessKey) {
if (!secretAccessKey) throw new Error('S3 secret key is required');
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket,
region,
accessKeyId,
secretAccessKey,
rootPath,
};
}
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
const source = isPlainObject(value) ? value : {};
const baseUrl = asTrimmedString(source.baseUrl);
const username = asTrimmedString(source.username);
const password = String(source.password ?? '');
const remotePath = normalizePath(source.remotePath);
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://');
}
if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required');
}
if (!allowIncomplete || password) {
if (!password) throw new Error('WebDAV password is required');
}
return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
username,
password,
remotePath,
};
}
function normalizeDestination(
destinationType: BackupDestinationType,
destination: unknown,
allowIncomplete = false
): BackupDestinationConfig {
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
return normalizeWebDavDestination(destination, allowIncomplete);
}
function normalizeRuntime(value: unknown): BackupRuntimeState {
const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => {
const raw = asTrimmedString(input);
if (!raw) return null;
const date = new Date(raw);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
};
const asMaybeNumber = (input: unknown): number | null => {
if (input === null || input === undefined || input === '') return null;
const n = Number(input);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
};
return {
lastAttemptAt: asIso(source.lastAttemptAt),
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
lastSuccessAt: asIso(source.lastSuccessAt),
lastErrorAt: asIso(source.lastErrorAt),
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
};
}
function defaultDestinationName(type: BackupDestinationType, index: number): string {
return createDefaultBackupDestinationName(type, index);
}
function getDestinationType(raw: unknown): BackupDestinationType {
const value = asTrimmedString(raw);
if (value === 'e3') return 's3';
if (value === 's3' || value === 'webdav') return value;
throw new Error('Backup destination type is invalid');
}
function normalizeDestinationRecord(
input: unknown,
previousById: Map<string, BackupDestinationRecord>,
index: number,
fallbackTimezone: string
): BackupDestinationRecord {
if (!isPlainObject(input)) {
throw new Error('Backup destination is invalid');
}
const id = asTrimmedString(input.id) || createBackupRandomId();
const type = getDestinationType(input.type);
const previous = previousById.get(id);
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
? scheduleSource.retentionCount
: previousSchedule.retentionCount;
const schedule: BackupScheduleConfig = {
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
intervalHours: normalizeIntervalHours(
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
),
startTime: normalizeStartTime(
scheduleSource.startTime ?? previousSchedule.startTime,
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
),
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
};
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
return {
id,
name,
type,
includeAttachments: typeof input.includeAttachments === 'boolean'
? input.includeAttachments
: previous?.includeAttachments ?? false,
destination,
schedule,
runtime,
};
}
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
const intervalHours = legacyFrequency === 'weekly'
? 24 * 7
: legacyFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
? getDestinationType(destinationTypeRaw)
: 'webdav';
const destination = {
id: createBackupRandomId(),
name: defaultDestinationName(destinationType, 1),
type: destinationType,
includeAttachments: false,
destination: normalizeDestination(destinationType, rawValue.destination),
schedule: {
enabled: !!rawValue.enabled,
intervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: 30,
},
runtime: normalizeRuntime(rawValue.runtime),
} satisfies BackupDestinationRecord;
return {
destinations: [destination],
};
}
function parseDestinations(
rawDestinations: unknown,
previousById: Map<string, BackupDestinationRecord>,
fallbackTimezone: string
): BackupDestinationRecord[] {
if (!Array.isArray(rawDestinations)) {
throw new Error('Backup destinations are invalid');
}
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
}
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
const ids = new Set<string>();
for (const destination of destinations) {
if (ids.has(destination.id)) {
throw new Error('Backup destination ids must be unique');
}
ids.add(destination.id);
}
return destinations;
}
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
return new Map(destinations.map((destination) => [destination.id, destination]));
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
}
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (Array.isArray(parsed.destinations)) {
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
const globalEnabled = !!parsed.enabled;
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
const globalIntervalHours = globalFrequency === 'weekly'
? 24 * 7
: globalFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const previousById = new Map<string, BackupDestinationRecord>();
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
if (!isPlainObject(entry)) return entry;
if (isPlainObject(entry.schedule)) return entry;
const entryId = asTrimmedString(entry.id);
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
return {
...entry,
schedule: {
enabled: scheduleEnabled,
intervalHours: globalIntervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: globalTimezone,
retentionCount: 30,
},
};
});
return {
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
};
}
return parseLegacyBackupSettings(parsed, fallbackTimezone);
} catch {
return getDefaultBackupSettings(fallbackTimezone);
}
}
export function normalizeBackupSettingsInput(
input: BackupSettingsInput,
previous: BackupSettings
): BackupSettings {
if (!isPlainObject(input)) {
throw new Error('Backup settings payload is invalid');
}
const previousById = mapDestinationsById(previous.destinations);
const rawDestinations = input.destinations ?? previous.destinations;
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
return {
destinations,
};
}
export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings);
}
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
}
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return settings;
}
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone);
} catch {
throw new Error('Backup settings need administrator reactivation after restore');
}
}
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers();
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
}
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) return;
const users = await storage.getAllUsers();
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
if (normalized !== null) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
}
}
export async function normalizeImportedBackupSettingsValue(
raw: string | null,
env: Env,
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
fallbackTimezone: string = 'UTC'
): Promise<string | null> {
if (!raw) return null;
const envelope = parseBackupSettingsEnvelope(raw);
if (envelope) {
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
return raw;
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
}
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return { needsRepair: false, portable: null };
}
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return { needsRepair: false, portable: null };
}
try {
await decryptBackupSettingsRuntime(raw, env);
return { needsRepair: false, portable: null };
} catch {
return {
needsRepair: true,
portable: envelope.portable,
};
}
}
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
await saveBackupSettings(storage, env, settings);
}
export function findBackupDestination(
settings: BackupSettings,
destinationId: string | null | undefined
): BackupDestinationRecord | null {
const normalizedId = asTrimmedString(destinationId);
if (!normalizedId) return null;
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
}
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
if (!destination) {
throw new Error('Backup destination not found');
}
return destination;
}
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
const parts = formatter.formatToParts(date);
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
return {
year: pick('year'),
month: pick('month'),
day: pick('day'),
hour: pick('hour'),
minute: pick('minute'),
};
}
export function getBackupLocalDateKey(date: Date, timezone: string): string {
const parts = getDateTimeParts(date, timezone);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function getBackupLocalTime(date: Date, timezone: string): string {
const parts = getDateTimeParts(date, timezone);
return `${parts.hour}:${parts.minute}`;
}
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
return { year, month, day };
}
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const actual = getDateTimeParts(new Date(utcGuess), timezone);
const actualUtc = Date.UTC(
Number(actual.year),
Number(actual.month) - 1,
Number(actual.day),
Number(actual.hour),
Number(actual.minute),
0,
0
);
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
return new Date(utcGuess - (actualUtc - desiredUtc));
}
function getBackupSlotStartsForLocalDay(
dateKey: string,
timezone: string,
startTime: string,
intervalHours: number
): Date[] {
const parsedDate = parseLocalDateKey(dateKey);
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
if (!parsedDate || parsedTime.length !== 2) return [];
const [hour, minute] = parsedTime;
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
const nextDay = getUtcDateForLocalTime(
timezone,
nextLocalDay.getUTCFullYear(),
nextLocalDay.getUTCMonth() + 1,
nextLocalDay.getUTCDate(),
0,
0
);
const intervalMs = intervalHours * 60 * 60 * 1000;
const slots: Date[] = [];
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
slots.push(new Date(slotMs));
}
return slots;
}
export function hasBackupSlotBetween(
destination: BackupDestinationRecord,
startInclusive: Date,
endExclusive: Date
): boolean {
if (!destination.schedule.enabled) return false;
const startMs = startInclusive.getTime();
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()
: Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs);
dayCursor.setUTCHours(0, 0, 0, 0);
const endDay = new Date(endMs);
endDay.setUTCHours(0, 0, 0, 0);
const checkedLocalDateKeys = new Set<string>();
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
if (!checkedLocalDateKeys.has(localDateKey)) {
checkedLocalDateKeys.add(localDateKey);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue;
return true;
}
}
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
}
return false;
}
export function isBackupDueNow(
destination: BackupDestinationRecord,
now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): 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()
: Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false;
return true;
}
return false;
}
+954
View File
@@ -0,0 +1,954 @@
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 {
type BackupManifestAttachmentBlob,
type BackupPayload,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
// CONTRACT:
// Restore is intentionally whitelist-based. Old backups may contain retired
// fields, but only the columns listed here are imported. Keep this file in sync
// with src/services/backup-archive.ts whenever backup contents change.
//
// WHEN CHANGING THIS:
// - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads,
// shadow-table count validation, insert column lists, and frontend import
// count types together.
// - Do not import users.api_key, even if an older backup contains it.
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'domain_settings'
| 'user_revisions'
| 'webauthn_credentials'
| 'folders'
| 'ciphers'
| 'attachments';
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'domain_settings',
'user_revisions',
'webauthn_credentials',
'folders',
'ciphers',
'attachments',
];
function shadowTableName(table: BackupTableName): string {
return `${table}__restore`;
}
export interface BackupImportResultBody {
object: 'instance-backup-import';
imported: {
config: number;
users: number;
domainSettings: number;
userRevisions: number;
webauthnCredentials: number;
folders: number;
ciphers: number;
attachments: number;
attachmentFiles: number;
};
skipped: {
reason: string | null;
attachments: number;
items: Array<{
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
};
}
export interface BackupImportExecutionResult {
result: BackupImportResultBody;
auditActorUserId: string | null;
}
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
return (response.results || []).map((row) => ({ ...row }));
}
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
const row = await db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.first<{ sql: string | null }>();
const sql = String(row?.sql || '').trim();
if (!sql) {
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
}
return sql;
}
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
if (next === createSql) {
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
}
for (const currentTable of BACKUP_TABLES) {
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
next = next.replace(
referencePattern,
`REFERENCES "${shadowTableName(currentTable)}"`
);
}
return next;
}
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
const dropStatements = BACKUP_TABLES
.slice()
.reverse()
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
if (dropStatements.length) {
await db.batch(dropStatements);
}
}
async function createShadowTables(db: D1Database): Promise<void> {
const createStatements: D1PreparedStatement[] = [];
for (const table of BACKUP_TABLES) {
const createSql = await getTableCreateSql(db, table);
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
}
await db.batch(createStatements);
}
async function validateShadowTableCounts(
db: D1Database,
expectedCounts: Partial<Record<BackupTableName, number>>
): Promise<void> {
await Promise.all(BACKUP_TABLES.map(async (table) => {
const expected = expectedCounts[table] ?? 0;
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
const actual = Number(row?.count || 0);
if (actual !== expected) {
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
}
}));
}
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
const statements: D1PreparedStatement[] = [];
// Commit by replacing live table contents from validated shadow tables.
// This avoids D1 schema-rename edge cases while keeping current data intact
// until the final batch succeeds.
for (const sql of buildResetImportTargetStatements(db)) {
statements.push(sql);
}
for (const table of BACKUP_TABLES) {
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
}
await db.batch(statements);
}
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
const counts = await Promise.all([
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
]);
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
if (total > 0) {
throw new Error('Backup import requires a fresh instance with no vault or send data');
}
}
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
return [
'DELETE FROM attachments',
'DELETE FROM ciphers',
'DELETE FROM folders',
'DELETE FROM webauthn_credentials',
'DELETE FROM domain_settings',
'DELETE FROM user_revisions',
'DELETE FROM users',
'DELETE FROM config',
].map((sql) => db.prepare(sql));
}
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
const keys = new Set<string>();
const attachmentRows = await queryRows(
db,
`SELECT a.id, a.cipher_id
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id`
);
for (const row of attachmentRows) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
}
return keys;
}
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured';
const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped';
interface BackupImportSkipSummary {
reason: string | null;
attachments: number;
items: Array<{
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
}
interface PreparedBackupImportPayload {
payload: BackupPayload;
skipped: BackupImportSkipSummary;
}
interface AttachmentRestoreResult {
imported: number;
restoredAttachments: SqlRow[];
skipped: BackupImportSkipSummary;
}
interface RemoteAttachmentSource {
loadAttachment(blobName: string): Promise<Uint8Array | null>;
}
export interface BackupRestoreProgressEvent {
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
replaceExisting: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
}
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
function attachmentRowKey(row: SqlRow): string {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
return `${cipherId}/${attachmentId}`;
}
function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row }));
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false;
const nextRows = rows.map((row) => {
if (String(row.key || '').trim() !== key) return { ...row };
replaced = true;
return { ...row, key, value };
});
if (!replaced) {
nextRows.push({ key, value });
}
return nextRows;
}
async function prepareImportedConfigRows(
env: Env,
configRows: SqlRow[],
userRows: SqlRow[]
): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
env,
userRows.map((row) => ({
id: String(row.id || '').trim(),
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
role: String(row.role || '').trim() as User['role'],
status: String(row.status || '').trim() as User['status'],
})),
'UTC'
);
if (normalizedBackupSettings !== null) {
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
}
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
return nextConfigRows;
}
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
const preparedDb: BackupPayload['db'] = {
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
})),
domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
archived_at: row.archived_at ?? null,
})),
attachments: cloneRows(payload.attachments || []),
};
await importBackupRows(db, preparedDb, true);
return preparedDb;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
const storageKind = getBlobStorageKind(env);
if (storageKind === 'r2') {
return {
payload,
skipped: {
reason: null,
attachments: 0,
items: [],
},
};
}
if (storageKind === null) {
const skippedItems = (payload.db.attachments || []).map((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
return {
kind: 'attachment' as const,
path: `attachments/${cipherId}/${attachmentId}.bin`,
sizeBytes: Number(row.size || 0) || 0,
};
});
const result = {
payload: {
...payload,
db: {
...payload.db,
attachments: [],
},
},
skipped: {
reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
return result;
}
const oversizedAttachmentPaths = new Set<string>();
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const entry of Object.keys(files)) {
if (!entry.endsWith('.bin')) continue;
const sizeBytes = files[entry].byteLength;
if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
if (entry.startsWith('attachments/')) {
oversizedAttachmentPaths.add(entry);
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
}
}
const nextAttachments = (payload.db.attachments || []).filter((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) return false;
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
});
const nextPayload: BackupPayload = {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
},
};
const needsKvBlobStorage = nextAttachments.length > 0;
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
}
const result = {
payload: nextPayload,
skipped: {
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
return result;
}
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
if (!rows.length) return [];
const placeholders = `(${columns.map(() => '?').join(', ')})`;
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
}
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
if (!statements.length) return;
try {
await db.batch(statements);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Restore insert failed for ${table}: ${message}`);
}
}
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
const key = `attachments/${cipherId}/${attachmentId}.bin`;
const bytes = files[key];
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: bytes.byteLength,
});
}
}
return {
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
}
async function prepareRemoteAttachmentPayload(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<PreparedBackupImportPayload> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const storageKind = getBlobStorageKind(env);
const nextAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const lookupKey = `${cipherId}/${attachmentId}`;
const ref = manifestLookup.get(lookupKey);
const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0;
const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`;
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
if (files[inlinePath]) {
nextAttachments.push(row);
continue;
}
if (!ref) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === null) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
nextAttachments.push(row);
}
const result = {
payload: {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
},
},
skipped: {
reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
return result;
}
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
if (!attachmentRows.length) return;
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
const statements = attachmentRows
.map((row) => {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!attachmentId || !cipherId) return null;
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
})
.filter((statement): statement is D1PreparedStatement => !!statement);
if (!statements.length) return;
await db.batch(statements);
}
async function restoreRemoteAttachmentFiles(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<{
imported: number;
skipped: BackupImportSkipSummary;
restoredAttachments: SqlRow[];
}> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
const ref = manifestLookup.get(`${cipherId}/${attachmentId}`);
if (!ref && !files[inlinePath]) {
skippedItems.push({
kind: 'attachment',
path: `attachments/${cipherId}/${attachmentId}`,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null);
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: bytes.byteLength,
});
}
}
return {
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
for (const key of staleKeys) {
await deleteBlobObject(env, key);
}
}
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
await runInsertBatch(
db,
tableName('config'),
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
);
await runInsertBatch(
db,
tableName('users'),
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'],
payload.users || []
)
);
await runInsertBatch(
db,
tableName('user_revisions'),
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
);
await runInsertBatch(
db,
tableName('domain_settings'),
buildInsertStatements(
db,
tableName('domain_settings'),
['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'],
payload.domain_settings || [],
true
)
);
await runInsertBatch(
db,
tableName('webauthn_credentials'),
buildInsertStatements(
db,
tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || []
)
);
await runInsertBatch(
db,
tableName('folders'),
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
);
await runInsertBatch(
db,
tableName('ciphers'),
buildInsertStatements(
db,
tableName('ciphers'),
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
payload.ciphers || []
)
);
await runInsertBatch(
db,
tableName('attachments'),
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
);
}
export async function importBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): Promise<BackupImportExecutionResult> {
const parsed = parseBackupArchive(archiveBytes);
validateBackupPayloadContents(parsed.payload, parsed.files);
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
try {
await ensureImportTargetIsFresh(env.DB);
} catch (error) {
if (!replaceExisting) {
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
try {
await progress?.({
source: 'local',
step: 'local_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'local',
step: 'local_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_local_data_title',
stageDetail: 'txt_backup_restore_progress_local_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
await progress?.({
source: 'local',
step: 'local_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_local_files_title',
stageDetail: 'txt_backup_restore_progress_local_files_detail',
replaceExisting,
});
const restored = await restoreBlobFiles(env, db, parsed.files);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'local',
step: 'local_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'local',
step: 'local_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: restored.skipped.reason || prepared.skipped.reason,
attachments: prepared.skipped.attachments + restored.skipped.attachments,
items: [...prepared.skipped.items, ...restored.skipped.items],
},
},
};
} catch (error) {
await progress?.({
source: 'local',
step: 'local_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
}
export async function importRemoteBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean,
source: RemoteAttachmentSource,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): Promise<BackupImportExecutionResult> {
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
try {
await ensureImportTargetIsFresh(env.DB);
} catch (error) {
if (!replaceExisting) {
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
try {
await progress?.({
source: 'remote',
step: 'remote_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'remote',
step: 'remote_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_data_title',
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
await progress?.({
source: 'remote',
step: 'remote_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_files_title',
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
replaceExisting,
});
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'remote',
step: 'remote_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'remote',
step: 'remote_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
const finalSkippedReason = finalSkippedItems.length
? restored.skipped.reason || preparedRemote.skipped.reason
: null;
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: finalSkippedReason,
attachments: finalSkippedItems.length,
items: finalSkippedItems,
},
},
};
} catch (error) {
await progress?.({
source: 'remote',
step: 'remote_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
}
+253
View File
@@ -0,0 +1,253 @@
import type { Env, User } from '../types';
// CONTRACT:
// Backup settings contain provider credentials. They are stored as a v2 envelope:
// - runtime: AES-GCM encrypted with a key derived from JWT_SECRET for the current
// server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
// active admin public keys so settings can be repaired after restore/migration.
// Historical/imported databases may not have usable admin public keys; in that
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
//
// New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider
// secrets as plain config JSON.
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
const RUNTIME_INFO = 'runtime';
const PORTABLE_ALGORITHM = 'RSA-OAEP';
const PORTABLE_HASH = 'SHA-1';
const AES_GCM_ALGORITHM = 'AES-GCM';
const AES_GCM_IV_BYTES = 12;
const PORTABLE_DEK_BYTES = 32;
export interface BackupSettingsRuntimeEnvelope {
iv: string;
ciphertext: string;
}
export interface BackupSettingsPortableWrap {
userId: string;
wrappedKey: string;
}
export interface BackupSettingsPortableEnvelope {
iv: string;
ciphertext: string;
wraps: BackupSettingsPortableWrap[];
}
export interface BackupSettingsEnvelopeV2 {
version: 2;
runtime: BackupSettingsRuntimeEnvelope;
portable: BackupSettingsPortableEnvelope;
}
function bytesToBase64(bytes: Uint8Array): string {
let text = '';
for (let index = 0; index < bytes.length; index += 1) {
text += String.fromCharCode(bytes[index]);
}
return btoa(text);
}
function base64ToBytes(value: string): Uint8Array {
const normalized = String(value || '').trim();
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
'HKDF',
false,
['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(RUNTIME_SALT),
info: encoder.encode(RUNTIME_INFO),
},
keyMaterial,
256
);
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
}
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: AES_GCM_ALGORITHM, iv },
key,
plaintext
)
);
return { iv, ciphertext };
}
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
return new Uint8Array(
await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv },
key,
ciphertext
)
);
}
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'spki',
base64ToBytes(publicKeyBase64),
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
false,
['encrypt']
);
}
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
return users
.filter(
(user) =>
user.role === 'admin' &&
user.status === 'active' &&
typeof user.publicKey === 'string' &&
user.publicKey.trim().length > 0
)
.map((user) => ({
id: user.id,
publicKey: user.publicKey!,
}));
}
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
const runtime = parsed.runtime;
const portable = parsed.portable;
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
if (!Array.isArray(portable.wraps)) return null;
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
return {
version: 2,
runtime: {
iv: runtime.iv,
ciphertext: runtime.ciphertext,
},
portable: {
iv: portable.iv,
ciphertext: portable.ciphertext,
wraps: portable.wraps
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
.map((entry) => ({
userId: String(entry.userId || '').trim(),
wrappedKey: String(entry.wrappedKey || '').trim(),
}))
.filter((entry) => entry.userId && entry.wrappedKey),
},
};
} catch {
return null;
}
}
export function exportPortableBackupSettingsEnvelope(raw: string | null): string | null {
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) return null;
return JSON.stringify({
version: 2,
portableOnly: true,
runtime: {
iv: '',
ciphertext: '',
},
portable: envelope.portable,
});
}
export async function encryptBackupSettingsEnvelope(
plaintext: string,
env: Env,
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
): Promise<string> {
const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users);
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
const portableKey = await crypto.subtle.importKey(
'raw',
portableDek,
{ name: AES_GCM_ALGORITHM },
false,
['encrypt']
);
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) {
try {
const publicKey = await importPortablePublicKey(user.publicKey!);
const wrappedKey = new Uint8Array(
await crypto.subtle.encrypt(
{ name: PORTABLE_ALGORITHM },
publicKey,
portableDek
)
);
wraps.push({
userId: user.id,
wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
}
const envelope: BackupSettingsEnvelopeV2 = {
version: 2,
runtime: {
iv: bytesToBase64(runtime.iv),
ciphertext: bytesToBase64(runtime.ciphertext),
},
portable: {
iv: bytesToBase64(portableCipher.iv),
ciphertext: bytesToBase64(portableCipher.ciphertext),
wraps,
},
};
return JSON.stringify(envelope);
}
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) {
throw new Error('Backup settings envelope is invalid');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const plaintext = await decryptAesGcm(
base64ToBytes(envelope.runtime.ciphertext),
base64ToBytes(envelope.runtime.iv),
runtimeKey
);
return new TextDecoder().decode(plaintext);
}
+789
View File
@@ -0,0 +1,789 @@
import {
BackupDestinationRecord,
BackupDestinationType,
S3BackupDestination,
WebDavBackupDestination,
} from './backup-config';
export interface BackupUploadResult {
provider: BackupDestinationType;
remotePath: string;
}
export interface RemoteBackupItem {
path: string;
name: string;
isDirectory: boolean;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupListResult {
provider: BackupDestinationType;
currentPath: string;
parentPath: string | null;
items: RemoteBackupItem[];
}
export interface RemoteBackupFile {
provider: BackupDestinationType;
remotePath: string;
fileName: string;
contentType: string;
bytes: Uint8Array;
}
export interface RemoteBackupFilePutOptions {
contentType?: string;
}
function isBackupArchiveName(name: string): boolean {
return /\.zip$/i.test(String(name || '').trim());
}
function encodePathSegments(path: string): string {
return path
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
function trimSlashes(value: string): string {
let next = String(value || '');
while (next.startsWith('/')) next = next.slice(1);
while (next.endsWith('/')) next = next.slice(0, -1);
return next;
}
function buildJoinedPath(...segments: string[]): string {
return segments.map(trimSlashes).filter(Boolean).join('/');
}
function normalizeRelativePath(path: string): string {
const normalized = trimSlashes(path).replace(/\\/g, '/');
if (!normalized) return '';
const parts = normalized.split('/').filter(Boolean);
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error('Invalid remote backup path');
}
return parts.join('/');
}
function basename(path: string): string {
const normalized = trimSlashes(path);
if (!normalized) return '';
const parts = normalized.split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function parentPath(path: string): string | null {
const normalized = normalizeRelativePath(path);
if (!normalized) return null;
const parts = normalized.split('/');
parts.pop();
return parts.length ? parts.join('/') : '';
}
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
return items.slice().sort((a, b) => {
const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments';
const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments';
if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1;
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name, 'en');
});
}
function decodeXmlText(value: string): string {
return value.replace(/&(amp|lt|gt|quot|#39);/g, (_match, entity) => {
switch (entity) {
case 'amp':
return '&';
case 'lt':
return '<';
case 'gt':
return '>';
case 'quot':
return '"';
case '#39':
return "'";
default:
return _match;
}
});
}
function parseHttpDate(value: string): string | null {
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
}
function extractXmlBlocks(xml: string, tagName: string): string[] {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
const blocks: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(xml))) {
blocks.push(match[1]);
}
return blocks;
}
function extractXmlFirst(xml: string, tagName: string): string | null {
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
const match = xml.match(pattern);
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
}
async function sha256Hex(value: Uint8Array | string): Promise<string> {
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
return new Uint8Array(signature);
}
function toBasicAuthHeader(username: string, password: string): string {
const token = btoa(`${username}:${password}`);
return `Basic ${token}`;
}
function buildCanonicalQueryString(url: URL): string {
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return aValue.localeCompare(bValue);
return aKey.localeCompare(bKey);
});
return params
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
async function buildAwsV4Authorization(
method: string,
url: URL,
headers: Record<string, string>,
payloadHashHex: string,
accessKeyId: string,
secretAccessKey: string,
region: string
): Promise<string> {
const amzDate = headers['x-amz-date'];
const shortDate = amzDate.slice(0, 8);
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
const canonicalHeaders = headerEntries
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
.join('\n');
const signedHeaders = headerEntries.map(([name]) => name).join(';');
const canonicalRequest = [
method.toUpperCase(),
url.pathname || '/',
buildCanonicalQueryString(url),
`${canonicalHeaders}\n`,
signedHeaders,
payloadHashHex,
].join('\n');
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
await sha256Hex(canonicalRequest),
].join('\n');
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
const kRegion = await hmacSha256Raw(kDate, region);
const kService = await hmacSha256Raw(kRegion, 's3');
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
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://');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required');
return;
}
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://');
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');
}
}
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
const trimmedBase = baseUrl.replace(/\/+$/, '');
const normalized = normalizeRelativePath(relativePath);
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
}
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
}
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
async function ensureWebDavDirectoryCached(
baseUrl: string,
directoryPath: string,
authHeader: string,
ensuredDirectories: Set<string>
): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
if (ensuredDirectories.has(current)) continue;
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
async function putToWebDav(
config: WebDavBackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {},
ensuredDirectories?: Set<string>
): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
const remoteDir = parentPath(remoteFilePath);
if (remoteDir) {
if (ensuredDirectories) {
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
} else {
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
}
}
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
method: 'PUT',
headers: {
Authorization: authHeader,
'Content-Type': options.contentType || 'application/octet-stream',
'Content-Length': String(bytes.byteLength),
},
body: bytes,
});
if (!response.ok) {
throw new Error(`WebDAV upload failed: ${response.status}`);
}
}
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToWebDav(config, fileName, archive, { contentType: 'application/zip' });
return {
provider: 'webdav',
remotePath: buildJoinedPath(config.remotePath, fileName),
};
}
function parseWebDavResponsePath(baseUrl: string, href: string): string {
const base = new URL(baseUrl);
const target = new URL(href, base);
const basePath = trimSlashes(decodeURIComponent(base.pathname));
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
if (!basePath) return entryPath;
if (entryPath === basePath) return '';
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
}
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
const currentPath = normalizeRelativePath(relativePath);
const targetFullPath = webDavFullPath(config, currentPath);
const authHeader = toBasicAuthHeader(config.username, config.password);
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
method: 'PROPFIND',
headers: {
Authorization: authHeader,
Depth: '1',
'Content-Type': 'application/xml; charset=utf-8',
},
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
});
if (response.status === 404) {
return {
provider: 'webdav',
currentPath,
parentPath: parentPath(currentPath),
items: [],
};
}
if (!response.ok) {
throw new Error(`WebDAV listing failed: ${response.status}`);
}
const xml = await response.text();
const rootFullPath = trimSlashes(config.remotePath);
const items: RemoteBackupItem[] = [];
for (const block of extractXmlBlocks(xml, 'response')) {
const href = extractXmlFirst(block, 'href');
if (!href) continue;
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
if (!fullPath) continue;
if (fullPath === targetFullPath) continue;
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
const relative = rootFullPath
? fullPath === rootFullPath
? ''
: fullPath.slice(rootFullPath.length + 1)
: fullPath;
if (!relative) continue;
const directParent = parentPath(relative);
if ((directParent || '') !== currentPath) continue;
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
items.push({
path: relative,
name: basename(relative) || relative,
isDirectory,
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
});
}
return {
provider: 'webdav',
currentPath,
parentPath: parentPath(currentPath),
items: sortRemoteItems(items),
};
}
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file');
}
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, normalized);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'GET',
headers: {
Authorization: authHeader,
},
});
if (!response.ok) {
throw new Error(`WebDAV download failed: ${response.status}`);
}
return {
provider: 'webdav',
remotePath: normalized,
fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
bytes: new Uint8Array(await response.arrayBuffer()),
};
}
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'DELETE',
headers: {
Authorization: authHeader,
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`WebDAV delete failed: ${response.status}`);
}
}
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
method: 'HEAD',
headers: {
Authorization: authHeader,
},
});
if (response.status === 404) return false;
if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`);
}
return true;
}
function s3BucketBaseUrl(config: S3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
}
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
}
async function signedS3Request(
config: S3BackupDestination,
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
url: URL,
body?: Uint8Array,
contentType?: string
): Promise<Response> {
const payloadHashHex = await sha256Hex(body || new Uint8Array());
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
const headers: Record<string, string> = {
host: url.host,
'x-amz-content-sha256': payloadHashHex,
'x-amz-date': amzDate,
};
if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream';
const authorization = await buildAwsV4Authorization(
method,
url,
headers,
payloadHashHex,
config.accessKeyId,
config.secretAccessKey,
config.region || 'auto'
);
return fetch(url.toString(), {
method,
headers: {
Authorization: authorization,
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
'X-Amz-Date': headers['x-amz-date'],
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
},
body,
});
}
async function putToS3(
config: S3BackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) {
throw new Error(`S3 upload failed: ${response.status}`);
}
}
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
return {
provider: 's3',
remotePath: normalizeS3ObjectKey(config, fileName),
};
}
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
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[] = [];
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)
: ''
: fullKey;
const normalizedRelative = trimSlashes(relative);
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
items.push({
path: normalizedRelative,
name: basename(normalizedRelative) || normalizedRelative,
isDirectory: false,
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
});
}
const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
return {
provider: 's3',
currentPath,
parentPath: parentPath(currentPath),
items: sortRemoteItems(Array.from(deduped.values())),
};
}
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file');
}
const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`);
}
return {
provider: 's3',
remotePath: normalized,
fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
bytes: new Uint8Array(await response.arrayBuffer()),
};
}
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`);
}
}
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false;
if (!response.ok) {
throw new Error(`S3 existence check failed: ${response.status}`);
}
return true;
}
interface ConfiguredDestinationAdapter {
provider: 'webdav' | 's3';
config: WebDavBackupDestination | S3BackupDestination;
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
}
export interface RemoteBackupTransferSession {
provider: BackupDestinationType;
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
list(relativePath: string): Promise<RemoteBackupListResult>;
download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
function resolveConfiguredDestinationAdapter(
destination: BackupDestinationRecord
): ConfiguredDestinationAdapter {
ensureDestinationConfigReady(destination);
if (destination.type === 'webdav') {
return {
provider: 'webdav',
config: destination.destination as WebDavBackupDestination,
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
};
}
if (destination.type === 's3') {
return {
provider: 's3',
config: destination.destination as S3BackupDestination,
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
};
}
throw new Error('Unsupported backup destination type');
}
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
const adapter = resolveConfiguredDestinationAdapter(destination);
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
const normalized = normalizeRelativePath(relativePath);
if (adapter.provider === 'webdav' && ensuredDirectories) {
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
return;
}
await adapter.putFile(adapter.config, normalized, bytes, options);
};
return {
provider: adapter.provider,
uploadArchive: async (archive: Uint8Array, fileName: string) => {
await putFile(fileName, archive, { contentType: 'application/zip' });
return {
provider: adapter.provider,
remotePath: adapter.provider === 'webdav'
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
};
},
putFile,
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
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)),
};
}
export async function uploadBackupArchive(
destination: BackupDestinationRecord,
archive: Uint8Array,
fileName: string
): Promise<BackupUploadResult> {
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
}
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
return createRemoteBackupTransferSession(destination).list(relativePath);
}
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
return createRemoteBackupTransferSession(destination).download(relativePath);
}
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
const normalized = ensureRemoteRestoreCandidate(relativePath);
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
}
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
const normalized = normalizeRelativePath(relativePath);
return createRemoteBackupTransferSession(destination).exists(normalized);
}
export async function uploadRemoteBackupFile(
destination: BackupDestinationRecord,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const normalized = normalizeRelativePath(relativePath);
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
}
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
if (preferredFileName) {
const aPreferred = a.name === preferredFileName ? 1 : 0;
const bPreferred = b.name === preferredFileName ? 1 : 0;
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
}
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
if (aTime !== bTime) return bTime - aTime;
return b.name.localeCompare(a.name, 'en');
}
export async function pruneRemoteBackupArchives(
destination: BackupDestinationRecord,
retentionCount: number | null,
preferredFileName?: string
): Promise<number> {
if (retentionCount === null) return 0;
const adapter = resolveConfiguredDestinationAdapter(destination);
const listing = await adapter.list(adapter.config, '');
const backupFiles = listing.items
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
if (backupFiles.length <= retentionCount) return 0;
for (const item of backupFiles.slice(retentionCount)) {
await adapter.deleteFile(adapter.config, item.path);
}
return backupFiles.length - retentionCount;
}
export function ensureRemoteRestoreCandidate(relativePath: string): string {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || !/\.zip$/i.test(normalized)) {
throw new Error('Please select a backup ZIP file');
}
return normalized;
}
+124
View File
@@ -0,0 +1,124 @@
import { Env } from '../types';
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024;
interface KVBlobMetadata {
size?: number;
contentType?: string;
customMetadata?: Record<string, string> | null;
}
export interface BlobObject {
body: ReadableStream | null;
size: number;
contentType: string;
}
export interface PutBlobOptions {
size: number;
contentType?: string;
customMetadata?: Record<string, string>;
}
function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } {
return !!env.ATTACHMENTS;
}
function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } {
return !!env.ATTACHMENTS_KV;
}
export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null {
// Keep R2 as preferred backend when both are bound.
if (hasR2Storage(env)) return 'r2';
if (hasKvStorage(env)) return 'kv';
return null;
}
export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number {
if (getBlobStorageKind(env) === 'kv') {
return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES);
}
return configuredLimit;
}
export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string {
return `${cipherId}/${attachmentId}`;
}
export function getSendFileObjectKey(sendId: string, fileId: string): string {
return `sends/${sendId}/${fileId}`;
}
export async function putBlobObject(
env: Env,
key: string,
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
options: PutBlobOptions
): Promise<void> {
const contentType = options.contentType || DEFAULT_CONTENT_TYPE;
if (hasR2Storage(env)) {
await env.ATTACHMENTS.put(key, value, {
httpMetadata: { contentType },
customMetadata: options.customMetadata,
});
return;
}
if (hasKvStorage(env)) {
if (options.size > KV_MAX_OBJECT_BYTES) {
throw new Error('KV object too large');
}
const metadata: KVBlobMetadata = {
size: options.size,
contentType,
customMetadata: options.customMetadata || null,
};
await env.ATTACHMENTS_KV.put(key, value, { metadata });
return;
}
throw new Error('Attachment storage is not configured');
}
export async function getBlobObject(env: Env, key: string): Promise<BlobObject | null> {
if (hasR2Storage(env)) {
const object = await env.ATTACHMENTS.get(key);
if (!object) return null;
return {
body: object.body,
size: Number(object.size) || 0,
contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
if (hasKvStorage(env)) {
const result = await env.ATTACHMENTS_KV.getWithMetadata<KVBlobMetadata>(key, 'arrayBuffer');
if (!result.value) return null;
const sizeFromMeta = Number(result.metadata?.size || 0);
const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength;
const body = new Response(result.value).body;
return {
body,
size,
contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE,
};
}
return null;
}
export async function deleteBlobObject(env: Env, key: string): Promise<void> {
if (hasR2Storage(env)) {
await env.ATTACHMENTS.delete(key);
return;
}
if (hasKvStorage(env)) {
await env.ATTACHMENTS_KV.delete(key);
return;
}
}
+222
View File
@@ -0,0 +1,222 @@
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
// CONTRACT:
// Equivalent domains are a Bitwarden compatibility surface. The DB stores both
// the full custom rule list and the derived active equivalent-domain groups:
// - custom_equivalent_domains: UI/client rules with id + excluded state.
// - equivalent_domains: active groups derived from non-excluded custom rules.
// - excluded_global_equivalent_domains: disabled global rule type ids.
// Do not treat equivalent_domains and custom_equivalent_domains as accidental
// duplicates without a migration and compatibility plan.
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
Type?: unknown;
Domains?: unknown;
Excluded?: unknown;
};
function normalizeDomain(value: unknown): string {
return normalizeEquivalentDomain(value);
}
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
const type = Number(entry.type ?? entry.Type);
if (!Number.isInteger(type)) return null;
const rawDomains = entry.domains ?? entry.Domains;
if (!Array.isArray(rawDomains)) return null;
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
if (domains.length < 2) return null;
return {
type,
domains,
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
};
}
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
if (!Array.isArray(input)) return [];
const seen = new Set<number>();
const out: GlobalEquivalentDomain[] = [];
for (const entry of input) {
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
if (!normalized || seen.has(normalized.type)) continue;
seen.add(normalized.type);
out.push(normalized);
}
return out;
}
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
export const globalDomains: readonly GlobalEquivalentDomain[] = [
...bitwardenGlobalDomains,
...customGlobalDomains,
];
export function normalizeEquivalentDomains(input: unknown): string[][] {
if (!Array.isArray(input)) return [];
const groups: string[][] = [];
const seenGroups = new Set<string>();
for (const group of input) {
if (!Array.isArray(group)) continue;
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
if (domains.length < 2) continue;
const key = domains.slice().sort().join('\n');
if (seenGroups.has(key)) continue;
seenGroups.add(key);
groups.push(domains);
}
return groups;
}
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
const parent = new Map<string, string>();
function find(domain: string): string {
const current = parent.get(domain);
if (!current) {
parent.set(domain, domain);
return domain;
}
if (current === domain) return domain;
const root = find(current);
parent.set(domain, root);
return root;
}
function union(a: string, b: string): void {
const rootA = find(a);
const rootB = find(b);
if (rootA !== rootB) parent.set(rootB, rootA);
}
for (const group of normalizeEquivalentDomains(input)) {
if (group.length < 2) continue;
const [first, ...rest] = group;
find(first);
for (const domain of rest) union(first, domain);
}
const components = new Map<string, string[]>();
for (const domain of parent.keys()) {
const root = find(domain);
const group = components.get(root) || [];
group.push(domain);
components.set(root, group);
}
return Array.from(components.values())
.map((group) => group.sort())
.filter((group) => group.length >= 2)
.sort((a, b) => a[0].localeCompare(b[0]));
}
export function expandCustomEquivalentDomainsWithGlobals(
customGroups: string[][],
activeGlobalGroups: string[][]
): string[][] {
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
if (!normalizedCustomGroups.length) return [];
const customDomains = new Set(normalizedCustomGroups.flat());
return mergeEquivalentDomainGroups([
...activeGlobalGroups,
...normalizedCustomGroups,
]).filter((group) => group.some((domain) => customDomains.has(domain)));
}
function createCustomDomainId(domains: string[], index: number): string {
return `custom:${domains.slice().sort().join('|')}:${index}`;
}
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
if (!Array.isArray(input)) return [];
const rules: CustomEquivalentDomain[] = [];
const seenGroups = new Set<string>();
for (const [index, item] of input.entries()) {
const record = Array.isArray(item)
? { domains: item, excluded: false, id: '' }
: item && typeof item === 'object'
? item as Record<string, unknown>
: null;
if (!record) continue;
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
if (!domains) continue;
const key = domains.slice().sort().join('\n');
if (seenGroups.has(key)) continue;
seenGroups.add(key);
const rawId = String(record.id ?? record.Id ?? '').trim();
rules.push({
id: rawId || createCustomDomainId(domains, index),
domains,
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
});
}
return rules;
}
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
return mergeEquivalentDomainGroups(rules
.filter((rule) => !rule.excluded)
.map((rule) => rule.domains));
}
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
if (!Array.isArray(input)) return [];
const validTypes = new Set(globalDomains.map((entry) => entry.type));
const seen = new Set<number>();
const out: number[] = [];
for (const item of input) {
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
const excluded = typeof item === 'object' && item !== null
? Boolean((item as Record<string, unknown>).excluded)
: true;
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
seen.add(type);
out.push(type);
}
return out;
}
export function buildDomainsResponse(
equivalentDomains: string[][],
customEquivalentDomains: CustomEquivalentDomain[],
excludedGlobalEquivalentDomains: number[],
options: { omitExcludedGlobals?: boolean } = {}
): DomainRulesResponse {
const excluded = new Set(excludedGlobalEquivalentDomains);
const activeGlobalDomainGroups = globalDomains
.filter((entry) => !excluded.has(entry.type))
.map((entry) => entry.domains);
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
equivalentDomains,
activeGlobalDomainGroups
);
const globals = globalDomains
.map((entry) => ({
type: entry.type,
domains: entry.domains,
excluded: excluded.has(entry.type),
}))
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
return {
equivalentDomains: mergedEquivalentDomains,
customEquivalentDomains,
globalEquivalentDomains: globals,
object: 'domains',
};
}
+198 -76
View File
@@ -1,33 +1,22 @@
import { LIMITS } from '../config/limits';
// D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per client IP.
// - API rate is tracked per identifier per fixed window.
// 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).
// Rate limit configuration
const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {}
@@ -52,16 +41,6 @@ export class RateLimitService {
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
@@ -158,8 +137,9 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
// Atomically consume one budget unit for the current fixed window.
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
// Cache API-backed fixed-window rate limiter.
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
private async consumeFixedWindowBudget(
identifier: string,
maxRequests: number,
@@ -168,68 +148,210 @@ export class RateLimitService {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
const ttl = Math.max(1, windowEnd - nowSec);
const writeResult = await this.db
.prepare(
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, maxRequests)
.run();
const cache = await caches.open('rate-limit');
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
// No changed row means conflict happened and WHERE prevented increment:
// current count is already at/above configured limit.
if ((writeResult.meta.changes ?? 0) === 0) {
return {
allowed: false,
remaining: 0,
retryAfterSeconds: windowEnd - nowSec,
};
const cached = await cache.match(cacheKey);
let count = 0;
if (cached) {
count = parseInt(await cached.text(), 10) || 0;
}
const row = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
.bind(identifier, windowStart)
.first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
if (count >= maxRequests) {
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
}
const remaining = Math.max(0, maxRequests - row.count);
return { allowed: true, remaining };
count++;
await cache.put(
cacheKey,
new Response(String(count), {
headers: { 'Cache-Control': `public, max-age=${ttl}` },
})
);
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
}
// Write budget for POST/PUT/DELETE/PATCH requests.
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
// 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
// budget helpers (write / sync / knownDevice / publicSend).
async consumeBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
// Read budget for GET /api/sync.
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(
identifier,
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
async consumeBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
}
}
export function getClientIdentifier(request: Request): string {
const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp;
function parseIpv4Octets(input: string): number[] | null {
const parts = input.split('.');
if (parts.length !== 4) return null;
const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim();
return 'unknown';
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
octets.push(value);
}
return octets;
}
function parseIpv6Hextets(input: string): number[] | null {
let value = input.trim().toLowerCase();
if (!value) return null;
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1);
}
const zoneIndex = value.indexOf('%');
if (zoneIndex >= 0) {
value = value.slice(0, zoneIndex);
}
if (!value.includes(':')) return null;
// Handle IPv4-mapped tail (e.g. ::ffff:192.0.2.1).
if (value.includes('.')) {
const lastColon = value.lastIndexOf(':');
if (lastColon < 0) return null;
const ipv4Tail = value.slice(lastColon + 1);
const octets = parseIpv4Octets(ipv4Tail);
if (!octets) return null;
const high = ((octets[0] << 8) | octets[1]).toString(16);
const low = ((octets[2] << 8) | octets[3]).toString(16);
value = `${value.slice(0, lastColon)}:${high}:${low}`;
}
const doubleColon = value.indexOf('::');
if (doubleColon !== value.lastIndexOf('::')) return null;
const parsePart = (part: string): number | null => {
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
const n = parseInt(part, 16);
return Number.isNaN(n) ? null : n;
};
const parseParts = (parts: string[]): number[] | null => {
const out: number[] = [];
for (const p of parts) {
if (!p) return null;
const n = parsePart(p);
if (n === null) return null;
out.push(n);
}
return out;
};
if (doubleColon >= 0) {
const [headRaw, tailRaw] = value.split('::');
const head = headRaw ? headRaw.split(':') : [];
const tail = tailRaw ? tailRaw.split(':') : [];
const headNums = parseParts(head);
const tailNums = parseParts(tail);
if (!headNums || !tailNums) return null;
const missing = 8 - (headNums.length + tailNums.length);
if (missing < 1) return null;
return [...headNums, ...new Array<number>(missing).fill(0), ...tailNums];
}
const all = parseParts(value.split(':'));
if (!all || all.length !== 8) return null;
return all;
}
function normalizeClientIpForRateLimit(rawIp: string): string | null {
const input = rawIp.trim();
if (!input) return null;
const ipv4 = parseIpv4Octets(input);
if (ipv4) {
return `ip4:${ipv4.join('.')}`;
}
const ipv6 = parseIpv6Hextets(input);
if (!ipv6) return null;
// Handle IPv4-mapped / IPv4-compatible IPv6 as IPv4 identity.
// Examples: ::ffff:192.0.2.1, ::192.0.2.1
if (
ipv6[0] === 0 &&
ipv6[1] === 0 &&
ipv6[2] === 0 &&
ipv6[3] === 0 &&
ipv6[4] === 0 &&
(ipv6[5] === 0xffff || ipv6[5] === 0)
) {
const octets = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff];
return `ip4:${octets.join('.')}`;
}
// Collapse to /64 to reduce brute-force bypass via IPv6 address rotation.
const prefix64 = ipv6
.slice(0, 4)
.map(part => part.toString(16).padStart(4, '0'))
.join(':');
return `ip6:${prefix64}`;
}
function isLocalRequest(request: Request): boolean {
const isLoopbackHost = (host: string | null): boolean => {
if (!host) return false;
const normalized = host.split(':')[0].trim().toLowerCase();
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized === '127.0.0.1' ||
normalized === '0.0.0.0' ||
normalized === '::1' ||
normalized === '[::1]'
);
};
try {
if (isLoopbackHost(new URL(request.url).hostname)) return true;
} catch {
// Ignore malformed URL and fall back to Host header check.
}
return isLoopbackHost(request.headers.get('Host'));
}
export function getClientIdentifier(request: Request): string | null {
// Strict fallback order:
// 1) CF-Connecting-IP
// 2) X-Real-IP
// 3) first item of X-Forwarded-For
// If none are present/valid, treat client IP as unavailable.
const candidates: Array<string | null> = [
request.headers.get('CF-Connecting-IP'),
request.headers.get('X-Real-IP'),
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() || null,
];
for (const raw of candidates) {
if (!raw) continue;
const normalized = normalizeClientIpForRateLimit(raw);
if (normalized) return normalized;
}
// Local dev (wrangler dev / localhost): allow a deterministic loopback identifier.
if (isLocalRequest(request)) {
return 'ip4:127.0.0.1';
}
return null;
}
@@ -0,0 +1,331 @@
import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
let accountPasskeySchemaReady = false;
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
{ name: 'id', sql: 'id TEXT' },
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
{ name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' },
{ name: 'type', sql: 'type TEXT' },
{ name: 'aa_guid', sql: 'aa_guid TEXT' },
{ name: 'transports', sql: 'transports TEXT' },
{ name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' },
{ name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' },
{ name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' },
{ name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' },
{ name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" },
{ name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" },
] as const;
const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [
'challenge_hash',
'scope',
'user_id',
'expires_at',
'used_at',
'created_at',
] as const;
async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise<Set<string>> {
const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>();
return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean));
}
async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
if (accountPasskeySchemaReady) return;
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
)
.run();
let credentialColumns = await tableColumns(db, 'webauthn_credentials');
for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) {
if (!credentialColumns.has(column.name)) {
await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run();
}
}
credentialColumns = await tableColumns(db, 'webauthn_credentials');
if (!credentialColumns.has('credential_id')) {
throw new Error('webauthn_credentials schema is missing credential_id');
}
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run();
await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run();
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
)
.run();
const challengeColumns = await tableColumns(db, 'webauthn_challenges');
const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column));
if (!challengeSchemaComplete) {
await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run();
await db
.prepare(
'CREATE TABLE webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)'
)
.run();
}
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run();
await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run();
accountPasskeySchemaReady = true;
}
function parseTransports(value: string | null): string[] | null {
if (!value) return null;
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return null;
return parsed.map((item) => String(item || '').trim()).filter(Boolean);
} catch {
return null;
}
}
function mapCredentialRow(row: {
id: string;
user_id: string;
name: string;
public_key: string;
credential_id: string;
counter: number;
type: string | null;
aa_guid: string | null;
transports: string | null;
encrypted_user_key: string | null;
encrypted_public_key: string | null;
encrypted_private_key: string | null;
supports_prf: number;
created_at: string;
updated_at: string;
}): AccountPasskeyCredential {
return {
id: row.id,
userId: row.user_id,
name: row.name,
publicKey: row.public_key,
credentialId: row.credential_id,
counter: Number(row.counter || 0),
type: row.type ?? null,
aaGuid: row.aa_guid ?? null,
transports: parseTransports(row.transports),
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
supportsPrf: !!row.supports_prf,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapChallengeRow(row: {
challenge_hash: string;
scope: AccountPasskeyChallengeScope;
user_id: string | null;
expires_at: number;
used_at: number | null;
created_at: number;
}): AccountPasskeyChallenge {
return {
challengeHash: row.challenge_hash,
scope: row.scope,
userId: row.user_id ?? null,
expiresAt: Number(row.expires_at || 0),
usedAt: row.used_at == null ? null : Number(row.used_at),
createdAt: Number(row.created_at || 0),
};
}
export async function saveAccountPasskeyCredential(
db: D1Database,
safeBind: SafeBindFn,
credential: AccountPasskeyCredential
): Promise<void> {
await ensureAccountPasskeySchema(db);
await safeBind(
db.prepare(
'INSERT INTO webauthn_credentials(' +
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
),
credential.id,
credential.userId,
credential.name,
credential.publicKey,
credential.credentialId,
credential.counter,
credential.type,
credential.aaGuid,
credential.transports ? JSON.stringify(credential.transports) : null,
credential.encryptedUserKey,
credential.encryptedPublicKey,
credential.encryptedPrivateKey,
credential.supportsPrf ? 1 : 0,
credential.createdAt,
credential.updatedAt
).run();
}
export async function listAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.all<any>();
return (rows.results || []).map(mapCredentialRow);
}
export async function getAccountPasskeyCredentialById(
db: D1Database,
userId: string,
id: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
.bind(userId, id)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function getAccountPasskeyCredentialByCredentialId(
db: D1Database,
credentialId: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
.bind(credentialId)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function countAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
): Promise<number> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
.bind(userId)
.first<{ count: number }>();
return Number(row?.count || 0);
}
export async function updateAccountPasskeyCounter(
db: D1Database,
userId: string,
credentialId: string,
counter: number,
updatedAt: string
): Promise<void> {
await ensureAccountPasskeySchema(db);
await db
.prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?')
.bind(counter, updatedAt, userId, credentialId)
.run();
}
export async function updateAccountPasskeyEncryption(
db: D1Database,
userId: string,
credentialId: string,
encryptedUserKey: string,
encryptedPublicKey: string,
encryptedPrivateKey: string,
updatedAt: string
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare(
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
'WHERE user_id = ? AND credential_id = ?'
)
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run();
return Number(result.meta.changes || 0) > 0;
}
export async function deleteAccountPasskeyCredential(
db: D1Database,
userId: string,
id: string
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
.bind(userId, id)
.run();
return Number(result.meta.changes || 0) > 0;
}
export async function saveAccountPasskeyChallenge(
db: D1Database,
challenge: AccountPasskeyChallenge
): Promise<void> {
await ensureAccountPasskeySchema(db);
await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run();
await db
.prepare(
'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at'
)
.bind(
challenge.challengeHash,
challenge.scope,
challenge.userId,
challenge.expiresAt,
challenge.usedAt,
challenge.createdAt
)
.run();
}
export async function consumeAccountPasskeyChallenge(
db: D1Database,
challengeHash: string,
scope: AccountPasskeyChallengeScope,
userId: string | null,
nowMs: number
): Promise<AccountPasskeyChallenge | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
.bind(challengeHash, scope)
.first<any>();
if (!row) return null;
const challenge = mapChallengeRow(row);
if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null;
if (userId !== null && challenge.userId !== userId) return null;
if (userId === null && challenge.userId !== null) return null;
const result = await db
.prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL')
.bind(nowMs, challengeHash)
.run();
if (Number(result.meta.changes || 0) <= 0) return null;
return { ...challenge, usedAt: nowMs };
}
+203
View File
@@ -0,0 +1,203 @@
import type { AuditLog, Invite } from '../types';
export interface AuditLogListOptions {
limit: number;
offset: number;
category?: string | null;
level?: string | null;
q?: string | null;
from?: string | null;
to?: string | null;
}
export interface AuditLogListResult {
logs: AuditLog[];
total: number;
hasMore: boolean;
}
function auditLogFromRow(row: any): AuditLog {
return {
id: row.id,
actorUserId: row.actor_user_id ?? null,
actorEmail: row.actor_email ?? null,
action: row.action,
category: row.category || 'system',
level: row.level || 'info',
targetType: row.target_type ?? null,
targetId: row.target_id ?? null,
targetUserEmail: row.target_user_email ?? null,
metadata: row.metadata ?? null,
createdAt: row.created_at,
};
}
function buildAuditWhere(options: AuditLogListOptions): { where: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
if (options.from) {
conditions.push('l.created_at >= ?');
params.push(options.from);
}
if (options.to) {
conditions.push('l.created_at <= ?');
params.push(options.to);
}
if (options.category) {
conditions.push('l.category = ?');
params.push(options.category);
}
if (options.level) {
conditions.push('l.level = ?');
params.push(options.level);
}
if (options.q) {
const q = options.q.toLowerCase().slice(0, 48);
const like = `%${q}%`;
conditions.push(
'(LOWER(l.action) LIKE ? OR LOWER(COALESCE(l.actor_user_id, \'\')) LIKE ? OR LOWER(COALESCE(l.target_type, \'\')) LIKE ? OR LOWER(COALESCE(l.target_id, \'\')) LIKE ? OR LOWER(COALESCE(actor.email, \'\')) LIKE ? OR LOWER(COALESCE(target.email, \'\')) LIKE ?)'
);
params.push(like, like, like, like, like, like);
}
return {
where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
params,
};
}
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
await db
.prepare(
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
.run();
}
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
const row = await db
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
.bind(code)
.first<any>();
if (!row) return null;
return {
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
const now = new Date().toISOString();
const predicate = includeInactive
? '1 = 1'
: "(status = 'active' AND expires_at > ?)";
const query =
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
`WHERE ${predicate} ORDER BY created_at DESC`;
const res = includeInactive
? await db.prepare(query).all<any>()
: await db.prepare(query).bind(now).all<any>();
return (res.results || []).map((row) => ({
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
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 > ?"
)
.bind(userId, now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revokeInvite(db: D1Database, code: 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'")
.bind(now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0);
}
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
await db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, category, level, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.category, log.level, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
export async function pruneAuditLogs(db: D1Database, beforeIso: string): Promise<number> {
const result = await db
.prepare('DELETE FROM audit_logs WHERE created_at < ?')
.bind(beforeIso)
.run();
return Number(result.meta.changes ?? 0);
}
export async function pruneAuditLogsToMax(db: D1Database, maxEntries: number): Promise<number> {
const limit = Math.max(1, Math.floor(maxEntries));
const result = await db
.prepare(
'DELETE FROM audit_logs WHERE id IN (' +
'SELECT id FROM audit_logs ORDER BY created_at DESC LIMIT -1 OFFSET ?' +
')'
)
.bind(limit)
.run();
return Number(result.meta.changes ?? 0);
}
export async function clearAuditLogs(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM audit_logs').run();
return Number(result.meta.changes ?? 0);
}
export async function listAuditLogs(db: D1Database, options: AuditLogListOptions): Promise<AuditLogListResult> {
const limit = Math.max(1, Math.min(200, Math.floor(options.limit || 50)));
const offset = Math.max(0, Math.floor(options.offset || 0));
const { where, params } = buildAuditWhere(options);
const rows = await db
.prepare(
'SELECT l.id, l.actor_user_id, actor.email AS actor_email, l.action, l.category, l.level, l.target_type, l.target_id, target.email AS target_user_email, l.metadata, l.created_at ' +
'FROM audit_logs l ' +
'LEFT JOIN users actor ON actor.id = l.actor_user_id ' +
"LEFT JOIN users target ON l.target_type = 'user' AND target.id = l.target_id " +
`${where} ORDER BY l.created_at DESC LIMIT ? OFFSET ?`
)
.bind(...params, limit + 1, offset)
.all<any>();
const results = rows.results || [];
const logs = results.slice(0, limit).map(auditLogFromRow);
const hasMore = results.length > limit;
return {
logs,
total: offset + logs.length + (hasMore ? 1 : 0),
hasMore,
};
}
+154
View File
@@ -0,0 +1,154 @@
import type { Attachment, Cipher } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type GetCipher = (id: string) => Promise<Cipher | null>;
type SaveCipher = (cipher: Cipher) => Promise<void>;
type UpdateRevisionDate = (userId: string) => Promise<string>;
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
const row = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
.bind(id)
.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'
);
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
}
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
}
export async function bulkDeleteAttachmentsByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
attachmentIds: string[]
): Promise<void> {
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!uniqueIds.length) return;
const chunkSize = sqlChunkSize(0);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
}
}
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
const res = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
.bind(cipherId)
.all<any>();
return (res.results || []).map((r) => ({
id: r.id,
cipherId: r.cipher_id,
fileName: r.file_name,
size: r.size,
sizeName: r.size_name,
key: r.key,
}));
}
export async function getAttachmentsByCipherIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
cipherIds: string[]
): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
if (cipherIds.length === 0) return grouped;
const uniqueCipherIds = [...new Set(cipherIds)];
const chunkSize = sqlChunkSize(0);
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...chunk)
.all<any>();
for (const row of res.results || []) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) list.push(item);
else grouped.set(item.cipherId, [item]);
}
}
return grouped;
}
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
const res = 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 c.user_id = ?`
)
.bind(userId)
.all<any>();
for (const row of res.results || []) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) list.push(item);
else grouped.set(item.cipherId, [item]);
}
return grouped;
}
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
}
export async function updateCipherRevisionDate(
getCipherById: GetCipher,
saveCipherRecord: SaveCipher,
updateRevisionDate: UpdateRevisionDate,
cipherId: string
): Promise<{ userId: string; revisionDate: string } | null> {
const cipher = await getCipherById(cipherId);
if (!cipher) return null;
cipher.updatedAt = new Date().toISOString();
await saveCipherRecord(cipher);
const revisionDate = await updateRevisionDate(cipher.userId);
return { userId: cipher.userId, revisionDate };
}
@@ -0,0 +1,46 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
await db
.prepare(
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, ' +
'expires_at INTEGER NOT NULL' +
')'
)
.run();
}
export async function consumeAttachmentDownloadToken(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
jti: string,
expUnixSeconds: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
const nowMs = Date.now();
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
.bind(nowMs)
.run();
cleanedUpAt = nowMs;
}
const expiresAtMs = expUnixSeconds * 1000;
const result = await db
.prepare(
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
'ON CONFLICT(jti) DO NOTHING'
)
.bind(jti, expiresAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+382
View File
@@ -0,0 +1,382 @@
import type { Cipher } from '../types';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
interface CipherRow {
id: string;
user_id: string;
type: number | null;
folder_id: string | null;
name: string | null;
notes: string | null;
favorite: number | null;
data: string;
reprompt: number | null;
key: string | null;
created_at: string;
updated_at: string;
archived_at: string | null;
deleted_at: string | null;
}
const CIPHER_SCALAR_DATA_KEYS = new Set([
'id',
'userId',
'user_id',
'type',
'folderId',
'folder_id',
'name',
'notes',
'favorite',
'reprompt',
'key',
'attachments',
'Attachments',
'attachments2',
'Attachments2',
'createdAt',
'created_at',
'creationDate',
'updatedAt',
'updated_at',
'revisionDate',
'archivedAt',
'archived_at',
'archivedDate',
'deletedAt',
'deleted_at',
'deletedDate',
]);
function buildCipherData(cipher: Cipher, folderId: string | null): string {
const payload: Record<string, unknown> = {
...cipher,
folderId,
};
for (const key of CIPHER_SCALAR_DATA_KEYS) {
delete payload[key];
}
return JSON.stringify(payload);
}
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null;
try {
const parsed = JSON.parse(row.data) as Cipher;
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
return {
...parsed,
id: row.id,
userId: row.user_id,
type: Number(row.type) || Number(parsed.type) || 1,
folderId,
name: row.name ?? parsed.name ?? null,
notes: row.notes ?? parsed.notes ?? null,
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
reprompt: row.reprompt ?? parsed.reprompt ?? 0,
key: row.key ?? parsed.key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null,
};
} catch {
console.error('Corrupted cipher data, id:', row.id);
return null;
}
}
function selectCipherColumns(): string {
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
}
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ?`)
.bind(id)
.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);
const stmt = db.prepare(
'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'
);
await safeBind(
stmt,
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
folderId,
cipher.name,
cipher.notes,
cipher.favorite ? 1 : 0,
data,
cipher.reprompt ?? 0,
cipher.key,
cipher.createdAt,
cipher.updatedAt,
cipher.archivedAt ?? null,
cipher.deletedAt
).run();
}
function sanitizeIds(ids: string[]): string[] {
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function bulkSoftDeleteCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET deleted_at = ?, updated_at = ?,
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, now, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkRestoreCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET deleted_at = NULL, updated_at = ?,
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkDeleteCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
}
return updateRevisionDate(userId);
}
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
const res = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC`)
.bind(userId)
.all<CipherRow>();
return (res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
});
}
export async function getCiphersPage(
db: D1Database,
userId: string,
includeDeleted: boolean,
limit: number,
offset: number
): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
const res = await db
.prepare(
`SELECT ${selectCipherColumns()} FROM ciphers
WHERE user_id = ?
${whereDeleted}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`
)
.bind(userId, limit, offset)
.all<CipherRow>();
return (res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
});
}
export async function getCiphersByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
ids: string[],
userId: string
): Promise<Cipher[]> {
if (ids.length === 0) return [];
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1);
const out: Cipher[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const stmt = db.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...chunk).all<CipherRow>();
out.push(
...(res.results || []).flatMap((row) => {
const cipher = parseCipherRow(row);
return cipher ? [cipher] : [];
})
);
}
return out;
}
export async function bulkMoveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
folderId: string | null,
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const now = new Date().toISOString();
const normalizedFolderId = normalizeOptionalId(folderId);
const uniqueIds = sanitizeIds(ids);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET folder_id = ?, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(normalizedFolderId, now, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkArchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
)
.bind(now, now, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkUnarchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = NULL, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
+22
View File
@@ -0,0 +1,22 @@
export async function isRegistered(db: D1Database): Promise<boolean> {
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
return row?.value === 'true';
}
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
await db
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind(key, value)
.run();
}
export async function setRegistered(db: D1Database): Promise<void> {
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind('registered', 'true')
.run();
}
+289
View File
@@ -0,0 +1,289 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>;
function mapDeviceRow(row: any): Device {
return {
userId: row.user_id,
deviceIdentifier: row.device_identifier,
name: row.name,
deviceNote: row.device_note ?? null,
type: row.type,
sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function upsertDevice(
db: D1Database,
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
const now = new Date().toISOString();
const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'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), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
effectiveName,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now,
now
)
.run();
}
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<boolean> {
const result = await db
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
.bind(String(name || '').trim(), userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function touchDeviceLastSeen(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(
keys.encryptedUserKey ?? null,
keys.encryptedPublicKey ?? null,
keys.encryptedPrivateKey ?? null,
now,
userId,
deviceIdentifier
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifiers: string[]
): Promise<number> {
const uniqueIds = Array.from(
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
);
if (!uniqueIds.length) return 0;
const placeholders = uniqueIds.map(() => '?').join(',');
const result = await db
.prepare(
`UPDATE devices
SET encrypted_user_key = NULL,
encrypted_public_key = NULL,
encrypted_private_key = NULL,
updated_at = ?
WHERE user_id = ? AND device_identifier IN (${placeholders})`
)
.bind(new Date().toISOString(), userId, ...uniqueIds)
.run();
return Number(result.meta.changes ?? 0);
}
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ '1': number }>();
return !!row;
}
export async function isKnownDeviceByEmail(
getUserByEmail: GetUserByEmail,
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
email: string,
deviceIdentifier: string
): Promise<boolean> {
const user = await getUserByEmail(email);
if (!user) return false;
return isKnownDeviceForUser(user.id, deviceIdentifier);
}
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(mapDeviceRow);
}
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
)
.bind(userId, deviceIdentifier)
.first<any>();
return row ? mapDeviceRow(row) : null;
}
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
return Number(result.meta.changes ?? 0);
}
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const res = await db
.prepare(
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map((row) => ({
deviceIdentifier: row.device_identifier,
expiresAt: Number(row.expires_at || 0),
tokenCount: Number(row.token_count || 0),
}));
}
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
const result = await db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
.bind(userId)
.run();
return Number(result.meta.changes ?? 0);
}
export async function updateTrustedTwoFactorTokensExpiryByDevice(
db: D1Database,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<number> {
const now = Date.now();
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const result = await db
.prepare('UPDATE trusted_two_factor_device_tokens SET expires_at = ? WHERE user_id = ? AND device_identifier = ? AND expires_at >= ?')
.bind(expiresAtMs, userId, deviceIdentifier, now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function saveTrustedTwoFactorDeviceToken(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
token: string,
userId: string,
deviceIdentifier: string,
expiresAtMs: number
): Promise<void> {
const tokenKey = await trustedTokenKey(token);
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
await db
.prepare(
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
)
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
.run();
}
export async function getTrustedTwoFactorDeviceTokenUserId(
db: D1Database,
trustedTokenKey: TrustedTokenKeyFn,
token: string,
deviceIdentifier: string
): Promise<string | null> {
const now = Date.now();
const tokenKey = await trustedTokenKey(token);
const row = await db
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
.bind(tokenKey, deviceIdentifier)
.first<{ user_id: string; expires_at: number }>();
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
return null;
}
return row.user_id;
}
+73
View File
@@ -0,0 +1,73 @@
import type { UserDomainSettings } from '../types';
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
// Storage adapter for the domain_settings table.
//
// CONTRACT:
// equivalent_domains is kept as the active derived groups for compatibility and
// fallback reads. custom_equivalent_domains is the full rule list that preserves
// UI/client state. Save both together through saveUserDomainSettings().
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed as T[] : fallback;
} catch {
return fallback;
}
}
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
const row = await db
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
.bind(userId)
.first<{
equivalent_domains: string | null;
custom_equivalent_domains: string | null;
excluded_global_equivalent_domains: string | null;
updated_at: string | null;
}>();
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
: [];
const customEquivalentDomains = storedCustomEquivalentDomains.length
? storedCustomEquivalentDomains
: normalizeCustomEquivalentDomains(equivalentDomains);
return {
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
updatedAt: row?.updated_at || null,
};
}
export async function saveUserDomainSettings(
db: D1Database,
userId: string,
equivalentDomains: string[][],
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
excludedGlobalEquivalentDomains: number[],
updatedAt: string
): Promise<void> {
await db
.prepare(
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
'VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id) DO UPDATE SET ' +
'equivalent_domains = excluded.equivalent_domains, ' +
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
'updated_at = excluded.updated_at'
)
.bind(
userId,
JSON.stringify(equivalentDomains),
JSON.stringify(customEquivalentDomains),
JSON.stringify(excludedGlobalEquivalentDomains),
updatedAt
)
.run();
}
+104
View File
@@ -0,0 +1,104 @@
import type { Folder } from '../types';
function mapFolderRow(row: any): Folder {
return {
id: row.id,
userId: row.user_id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
.bind(id)
.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'
)
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run();
}
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function clearFolderFromCiphers(
db: D1Database,
userId: string,
folderId: string
): Promise<void> {
const now = new Date().toISOString();
await 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 = ?`
)
.bind(now, userId, folderId)
.run();
}
export async function bulkDeleteFolders(
db: D1Database,
userId: string,
ids: string[],
sqlChunkSize: (fixedBindCount: 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);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await 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})`
)
.bind(now, userId, ...chunk)
.run();
await db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
.bind(userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
const res = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
.bind(userId)
.all<any>();
return (res.results || []).map((row) => mapFolderRow(row));
}
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
const res = await db
.prepare(
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map((row) => mapFolderRow(row));
}
+100
View File
@@ -0,0 +1,100 @@
import type { RefreshTokenRecord } from '../types';
type RefreshTokenKeyFn = (token: string) => Promise<string>;
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
export async function saveRefreshToken(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
token: string,
userId: string,
expiresAtMs: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
): Promise<void> {
await maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await refreshTokenKey(token);
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'
)
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
.run();
}
export async function getRefreshTokenRecord(
db: D1Database,
refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string
): Promise<RefreshTokenRecord | null> {
const now = Date.now();
await maybeCleanupExpiredRefreshTokens(now);
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 = ?')
.bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
return {
userId: row.user_id,
expiresAt: row.expires_at,
deviceIdentifier: row.device_identifier ?? null,
deviceSessionStamp: row.device_session_stamp ?? null,
};
}
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();
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
}
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
return Number(result.meta.changes ?? 0);
}
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
const result = await db
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.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();
}
+31
View File
@@ -0,0 +1,31 @@
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
const row = await db
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
.bind(userId)
.first<{ revision_date: string }>();
if (row?.revision_date) return row.revision_date;
const date = new Date().toISOString();
await db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO NOTHING'
)
.bind(userId, date)
.run();
return date;
}
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
const date = new Date().toISOString();
await db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
)
.bind(userId, date)
.run();
return date;
}
+172
View File
@@ -0,0 +1,172 @@
// IMPORTANT:
// This is the runtime D1 schema bootstrap. Keep it in sync with
// migrations/0001_init.sql. Any new table/column/index must be added to both
// places together.
//
// WHEN CHANGING THIS:
// - Bump STORAGE_SCHEMA_VERSION in src/services/storage.ts so existing installs
// rerun these idempotent statements.
// - If the new table stores persistent data, update the backup export/import
// contract in src/services/backup-archive.ts and backup-import.ts.
// - Keep statements idempotent; D1 may execute them again on later requests.
const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS users (' +
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'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 totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' +
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE TABLE IF NOT EXISTS ciphers (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
'CREATE TABLE IF NOT EXISTS folders (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
'CREATE TABLE IF NOT EXISTS attachments (' +
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
'size_name TEXT NOT NULL, key TEXT, ' +
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
'CREATE TABLE IF NOT EXISTS sends (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
'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, ' +
'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',
'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)',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, category TEXT NOT NULL DEFAULT \'system\', level TEXT NOT NULL DEFAULT \'info\', target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE audit_logs ADD COLUMN category TEXT NOT NULL DEFAULT \'system\'',
'ALTER TABLE audit_logs ADD COLUMN level TEXT NOT NULL DEFAULT \'info\'',
'UPDATE audit_logs SET category = json_extract(metadata, \'$.category\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.category\') IN (\'auth\', \'security\', \'device\', \'data\', \'system\')',
'UPDATE audit_logs SET level = json_extract(metadata, \'$.level\') WHERE json_valid(metadata) AND json_extract(metadata, \'$.level\') IN (\'info\', \'warn\', \'error\', \'security\')',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_category_created ON audit_logs(category, created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
'CREATE TABLE IF NOT EXISTS webauthn_challenges (' +
'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
];
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
try {
await db.prepare(statement).run();
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
return;
}
throw error;
}
}
async function ensureAdminUserExists(db: D1Database): Promise<void> {
const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
if (admin?.id) return;
const firstUser = await db
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
.first<{ id: string }>();
if (!firstUser?.id) return;
await db
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
.bind(new Date().toISOString(), firstUser.id)
.run();
}
export async function ensureStorageSchema(db: D1Database): Promise<void> {
await db.prepare('PRAGMA foreign_keys = ON').run();
await db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
for (const stmt of SCHEMA_STATEMENTS) {
await executeSchemaStatement(db, stmt);
}
await ensureAdminUserExists(db);
}
+163
View File
@@ -0,0 +1,163 @@
import type { Send } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
function mapSendRow(row: any): Send {
return {
id: row.id,
userId: row.user_id,
type: row.type,
name: row.name,
notes: row.notes,
data: row.data,
key: row.key,
passwordHash: row.password_hash,
passwordSalt: row.password_salt,
passwordIterations: row.password_iterations,
authType: row.auth_type ?? 0,
emails: row.emails ?? null,
maxAccessCount: row.max_access_count,
accessCount: row.access_count,
disabled: !!row.disabled,
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
createdAt: row.created_at,
updatedAt: row.updated_at,
expirationDate: row.expiration_date,
deletionDate: row.deletion_date,
};
}
export async function getSend(db: D1Database, id: 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 = ?'
)
.bind(id)
.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, ' +
'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'
);
await safeBind(
stmt,
send.id,
send.userId,
Number(send.type) || 0,
send.name,
send.notes,
send.data,
send.key,
send.passwordHash,
send.passwordSalt,
send.passwordIterations,
send.authType,
send.emails,
send.maxAccessCount,
send.accessCount,
send.disabled ? 1 : 0,
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
send.createdAt,
send.updatedAt,
send.expirationDate,
send.deletionDate
).run();
}
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
const now = new Date().toISOString();
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)'
)
.bind(now, sendId)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
export async function getSendsByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
ids: string[],
userId: string
): Promise<Send[]> {
if (ids.length === 0) return [];
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1);
const out: Send[] = [];
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = 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 user_id = ? AND id IN (${placeholders})`
)
.bind(userId, ...chunk)
.all<any>();
out.push(...(res.results || []).map((row) => mapSendRow(row)));
}
return out;
}
export async function bulkDeleteSends(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
}
return updateRevisionDate(userId);
}
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
const res = 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 user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map((row) => mapSendRow(row));
}
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
const res = 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 user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map((row) => mapSendRow(row));
}
+142
View File
@@ -0,0 +1,142 @@
import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
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';
function mapUserRow(row: any): User {
return {
id: row.id,
email: row.email,
name: row.name,
masterPasswordHint: row.master_password_hint ?? null,
masterPasswordHash: row.master_password_hash,
key: row.key,
privateKey: row.private_key,
publicKey: row.public_key,
kdfType: row.kdf_type,
kdfIterations: row.kdf_iterations,
kdfMemory: row.kdf_memory ?? undefined,
kdfParallelism: row.kdf_parallelism ?? undefined,
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,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
apiKey: row.api_key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getUser(db: D1Database, email: string): Promise<User | null> {
const row = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
return mapUserRow(row);
}
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
const row = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
.bind(id)
.first<any>();
if (!row) return null;
return mapUserRow(row);
}
export async function getUserCount(db: D1Database): Promise<number> {
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
return Number(row?.count || 0);
}
export async function getAllUsers(db: D1Database): Promise<User[]> {
const res = await db
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
.all<any>();
return (res.results || []).map((row) => mapUserRow(row));
}
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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'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'
);
await safeBind(
stmt,
user.id,
email,
user.name,
user.masterPasswordHint,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory,
user.kdfParallelism,
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.apiKey,
user.createdAt,
user.updatedAt
).run();
}
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
await saveUser(db, safeBind, 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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(
stmt,
user.id,
email,
user.name,
user.masterPasswordHint,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory,
user.kdfParallelism,
user.securityStamp,
user.role,
user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.apiKey,
user.createdAt,
user.updatedAt
).run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
return (result.meta.changes ?? 0) > 0;
}
+593 -491
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
[
{"type":2,"domains":["ameritrade.com","tdameritrade.com"],"excluded":false},
{"type":3,"domains":["bankofamerica.com","bofa.com","mbna.com","usecfo.com"],"excluded":false},
{"type":4,"domains":["sprint.com","sprintpcs.com","nextel.com"],"excluded":false},
{"type":0,"domains":["youtube.com","google.com","gmail.com"],"excluded":false},
{"type":1,"domains":["apple.com","icloud.com"],"excluded":false},
{"type":5,"domains":["wellsfargo.com","wf.com","wellsfargoadvisors.com"],"excluded":false},
{"type":6,"domains":["mymerrill.com","ml.com","merrilledge.com"],"excluded":false},
{"type":7,"domains":["accountonline.com","citi.com","citibank.com","citicards.com","citibankonline.com"],"excluded":false},
{"type":8,"domains":["cnet.com","cnettv.com","com.com","download.com","news.com","search.com","upload.com"],"excluded":false},
{"type":9,"domains":["bananarepublic.com","gap.com","oldnavy.com","piperlime.com"],"excluded":false},
{"type":10,"domains":["bing.com","hotmail.com","live.com","microsoft.com","msn.com","passport.net","windows.com","microsoftonline.com","office.com","office365.com","microsoftstore.com","xbox.com","azure.com","windowsazure.com","cloud.microsoft"],"excluded":false},
{"type":11,"domains":["ua2go.com","ual.com","united.com","unitedwifi.com"],"excluded":false},
{"type":12,"domains":["overture.com","yahoo.com"],"excluded":false},
{"type":13,"domains":["zonealarm.com","zonelabs.com"],"excluded":false},
{"type":14,"domains":["paypal.com","paypal-search.com"],"excluded":false},
{"type":15,"domains":["avon.com","youravon.com"],"excluded":false},
{"type":16,"domains":["diapers.com","soap.com","wag.com","yoyo.com","beautybar.com","casa.com","afterschool.com","vine.com","bookworm.com","look.com","vinemarket.com"],"excluded":false},
{"type":17,"domains":["1800contacts.com","800contacts.com"],"excluded":false},
{"type":18,"domains":["amazon.com","amazon.com.be","amazon.ae","amazon.ca","amazon.co.uk","amazon.com.au","amazon.com.br","amazon.com.mx","amazon.com.tr","amazon.de","amazon.es","amazon.fr","amazon.in","amazon.it","amazon.nl","amazon.pl","amazon.sa","amazon.se","amazon.sg"],"excluded":false},
{"type":19,"domains":["cox.com","cox.net","coxbusiness.com"],"excluded":false},
{"type":20,"domains":["mynortonaccount.com","norton.com"],"excluded":false},
{"type":21,"domains":["verizon.com","verizon.net"],"excluded":false},
{"type":22,"domains":["rakuten.com","buy.com"],"excluded":false},
{"type":23,"domains":["siriusxm.com","sirius.com"],"excluded":false},
{"type":24,"domains":["ea.com","origin.com","play4free.com","tiberiumalliance.com"],"excluded":false},
{"type":25,"domains":["37signals.com","basecamp.com","basecamphq.com","highrisehq.com"],"excluded":false},
{"type":26,"domains":["steampowered.com","steamcommunity.com","steamgames.com"],"excluded":false},
{"type":27,"domains":["chart.io","chartio.com"],"excluded":false},
{"type":28,"domains":["gotomeeting.com","citrixonline.com"],"excluded":false},
{"type":29,"domains":["gogoair.com","gogoinflight.com"],"excluded":false},
{"type":30,"domains":["mysql.com","oracle.com"],"excluded":false},
{"type":31,"domains":["discover.com","discovercard.com"],"excluded":false},
{"type":32,"domains":["dcu.org","dcu-online.org"],"excluded":false},
{"type":33,"domains":["healthcare.gov","cuidadodesalud.gov","cms.gov"],"excluded":false},
{"type":34,"domains":["pepco.com","pepcoholdings.com"],"excluded":false},
{"type":35,"domains":["century21.com","21online.com"],"excluded":false},
{"type":36,"domains":["comcast.com","comcast.net","xfinity.com"],"excluded":false},
{"type":37,"domains":["cricketwireless.com","aiowireless.com"],"excluded":false},
{"type":38,"domains":["mandtbank.com","mtb.com"],"excluded":false},
{"type":39,"domains":["dropbox.com","getdropbox.com"],"excluded":false},
{"type":40,"domains":["snapfish.com","snapfish.ca"],"excluded":false},
{"type":41,"domains":["alibaba.com","aliexpress.com","aliyun.com","net.cn"],"excluded":false},
{"type":42,"domains":["playstation.com","sonyentertainmentnetwork.com"],"excluded":false},
{"type":43,"domains":["mercadolivre.com","mercadolivre.com.br","mercadolibre.com","mercadolibre.com.ar","mercadolibre.com.mx"],"excluded":false},
{"type":44,"domains":["zendesk.com","zopim.com"],"excluded":false},
{"type":45,"domains":["autodesk.com","tinkercad.com"],"excluded":false},
{"type":46,"domains":["railnation.ru","railnation.de","rail-nation.com","railnation.gr","railnation.us","trucknation.de","traviangames.com"],"excluded":false},
{"type":47,"domains":["wpcu.coop","wpcuonline.com"],"excluded":false},
{"type":48,"domains":["mathletics.com","mathletics.com.au","mathletics.co.uk"],"excluded":false},
{"type":49,"domains":["discountbank.co.il","telebank.co.il"],"excluded":false},
{"type":50,"domains":["mi.com","xiaomi.com"],"excluded":false},
{"type":52,"domains":["postepay.it","poste.it"],"excluded":false},
{"type":51,"domains":["facebook.com","messenger.com"],"excluded":false},
{"type":53,"domains":["skysports.com","skybet.com","skyvegas.com"],"excluded":false},
{"type":54,"domains":["disneymoviesanywhere.com","go.com","disney.com","dadt.com","disneyplus.com"],"excluded":false},
{"type":55,"domains":["pokemon-gl.com","pokemon.com"],"excluded":false},
{"type":56,"domains":["myuv.com","uvvu.com"],"excluded":false},
{"type":58,"domains":["mdsol.com","imedidata.com"],"excluded":false},
{"type":57,"domains":["bank-yahav.co.il","bankhapoalim.co.il"],"excluded":false},
{"type":59,"domains":["sears.com","shld.net"],"excluded":false},
{"type":60,"domains":["xiami.com","alipay.com"],"excluded":false},
{"type":61,"domains":["belkin.com","seedonk.com"],"excluded":false},
{"type":62,"domains":["turbotax.com","intuit.com"],"excluded":false},
{"type":63,"domains":["shopify.com","myshopify.com"],"excluded":false},
{"type":64,"domains":["ebay.com","ebay.at","ebay.be","ebay.ca","ebay.ch","ebay.cn","ebay.co.jp","ebay.co.th","ebay.co.uk","ebay.com.au","ebay.com.hk","ebay.com.my","ebay.com.sg","ebay.com.tw","ebay.de","ebay.es","ebay.fr","ebay.ie","ebay.in","ebay.it","ebay.nl","ebay.ph","ebay.pl"],"excluded":false},
{"type":65,"domains":["techdata.com","techdata.ch"],"excluded":false},
{"type":66,"domains":["schwab.com","schwabplan.com"],"excluded":false},
{"type":68,"domains":["tesla.com","teslamotors.com"],"excluded":false},
{"type":69,"domains":["morganstanley.com","morganstanleyclientserv.com","stockplanconnect.com","ms.com"],"excluded":false},
{"type":70,"domains":["taxact.com","taxactonline.com"],"excluded":false},
{"type":71,"domains":["mediawiki.org","wikibooks.org","wikidata.org","wikimedia.org","wikinews.org","wikipedia.org","wikiquote.org","wikisource.org","wikiversity.org","wikivoyage.org","wiktionary.org"],"excluded":false},
{"type":72,"domains":["airbnb.at","airbnb.be","airbnb.ca","airbnb.ch","airbnb.cl","airbnb.co.cr","airbnb.co.id","airbnb.co.in","airbnb.co.kr","airbnb.co.nz","airbnb.co.uk","airbnb.co.ve","airbnb.com","airbnb.com.ar","airbnb.com.au","airbnb.com.bo","airbnb.com.br","airbnb.com.bz","airbnb.com.co","airbnb.com.ec","airbnb.com.gt","airbnb.com.hk","airbnb.com.hn","airbnb.com.mt","airbnb.com.my","airbnb.com.ni","airbnb.com.pa","airbnb.com.pe","airbnb.com.py","airbnb.com.sg","airbnb.com.sv","airbnb.com.tr","airbnb.com.tw","airbnb.cz","airbnb.de","airbnb.dk","airbnb.es","airbnb.fi","airbnb.fr","airbnb.gr","airbnb.gy","airbnb.hu","airbnb.ie","airbnb.is","airbnb.it","airbnb.jp","airbnb.mx","airbnb.nl","airbnb.no","airbnb.pl","airbnb.pt","airbnb.ru","airbnb.se"],"excluded":false},
{"type":73,"domains":["eventbrite.at","eventbrite.be","eventbrite.ca","eventbrite.ch","eventbrite.cl","eventbrite.co","eventbrite.co.nz","eventbrite.co.uk","eventbrite.com","eventbrite.com.ar","eventbrite.com.au","eventbrite.com.br","eventbrite.com.mx","eventbrite.com.pe","eventbrite.de","eventbrite.dk","eventbrite.es","eventbrite.fi","eventbrite.fr","eventbrite.hk","eventbrite.ie","eventbrite.it","eventbrite.nl","eventbrite.pt","eventbrite.se","eventbrite.sg"],"excluded":false},
{"type":74,"domains":["stackexchange.com","superuser.com","stackoverflow.com","serverfault.com","mathoverflow.net","askubuntu.com","stackapps.com"],"excluded":false},
{"type":75,"domains":["docusign.com","docusign.net"],"excluded":false},
{"type":76,"domains":["envato.com","themeforest.net","codecanyon.net","videohive.net","audiojungle.net","graphicriver.net","photodune.net","3docean.net"],"excluded":false},
{"type":77,"domains":["x10hosting.com","x10premium.com"],"excluded":false},
{"type":78,"domains":["dnsomatic.com","opendns.com","umbrella.com"],"excluded":false},
{"type":79,"domains":["cagreatamerica.com","canadaswonderland.com","carowinds.com","cedarfair.com","cedarpoint.com","dorneypark.com","kingsdominion.com","knotts.com","miadventure.com","schlitterbahn.com","valleyfair.com","visitkingsisland.com","worldsoffun.com"],"excluded":false},
{"type":80,"domains":["ubnt.com","ui.com"],"excluded":false},
{"type":81,"domains":["discordapp.com","discord.com"],"excluded":false},
{"type":82,"domains":["netcup.de","netcup.eu","customercontrolpanel.de"],"excluded":false},
{"type":83,"domains":["yandex.com","ya.ru","yandex.az","yandex.by","yandex.co.il","yandex.com.am","yandex.com.ge","yandex.com.tr","yandex.ee","yandex.fi","yandex.fr","yandex.kg","yandex.kz","yandex.lt","yandex.lv","yandex.md","yandex.pl","yandex.ru","yandex.tj","yandex.tm","yandex.ua","yandex.uz"],"excluded":false},
{"type":84,"domains":["sonyentertainmentnetwork.com","sony.com"],"excluded":false},
{"type":85,"domains":["proton.me","protonmail.com","protonvpn.com"],"excluded":false},
{"type":86,"domains":["ubisoft.com","ubi.com"],"excluded":false},
{"type":87,"domains":["transferwise.com","wise.com"],"excluded":false},
{"type":88,"domains":["takeaway.com","just-eat.dk","just-eat.no","just-eat.fr","just-eat.ch","lieferando.de","lieferando.at","thuisbezorgd.nl","pyszne.pl"],"excluded":false},
{"type":89,"domains":["atlassian.com","bitbucket.org","trello.com","statuspage.io","atlassian.net","jira.com"],"excluded":false},
{"type":90,"domains":["pinterest.com","pinterest.com.au","pinterest.cl","pinterest.de","pinterest.dk","pinterest.es","pinterest.fr","pinterest.co.uk","pinterest.jp","pinterest.co.kr","pinterest.nz","pinterest.pt","pinterest.se"],"excluded":false},
{"type":91,"domains":["twitter.com","x.com"],"excluded":false}
]
@@ -0,0 +1,15 @@
{
"source": "https://github.com/bitwarden/server",
"ref": "main",
"generatedAt": "2026-05-05T00:00:00.000Z",
"rulesCount": 91,
"domainsCount": 436,
"sourceFiles": [
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
"src/Core/Utilities/StaticStore.cs"
],
"sourceUrls": [
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
]
}
+3
View File
@@ -0,0 +1,3 @@
[
{"type":-10001,"domains":["nodewarden.example","nw.example"],"excluded":false,"source":"nodewarden"}
]
+268 -3
View File
@@ -1,10 +1,24 @@
// Environment bindings
export interface Env {
DB: D1Database;
ATTACHMENTS: R2Bucket;
NOTIFICATIONS_HUB: DurableObjectNamespace;
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
// Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string;
WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: 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';
@@ -24,6 +38,7 @@ export interface User {
id: string;
email: string;
name: string | null;
masterPasswordHint: string | null;
masterPasswordHash: string;
key: string;
privateKey: string | null;
@@ -33,10 +48,68 @@ export interface User {
kdfMemory?: number;
kdfParallelism?: number;
securityStamp: string;
role: UserRole;
status: UserStatus;
verifyDevices?: boolean;
totpSecret: string | null;
totpRecoveryCode: string | null;
apiKey: string | null;
createdAt: string;
updatedAt: string;
}
export interface UserDomainSettings {
userId: string;
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
excludedGlobalEquivalentDomains: number[];
updatedAt: string | null;
}
export interface CustomEquivalentDomain {
id: string;
domains: string[];
excluded: boolean;
}
export interface GlobalEquivalentDomain {
type: number;
domains: string[];
excluded: boolean;
[key: string]: unknown;
}
export interface DomainRulesResponse {
equivalentDomains: string[][];
customEquivalentDomains: CustomEquivalentDomain[];
globalEquivalentDomains: GlobalEquivalentDomain[];
object: 'domains';
}
export interface Invite {
code: string;
createdBy: string;
usedBy: string | null;
expiresAt: string;
status: 'active' | 'used' | 'revoked' | 'expired';
createdAt: string;
updatedAt: string;
}
export interface AuditLog {
id: string;
actorUserId: string | null;
actorEmail?: string | null;
action: string;
category: 'auth' | 'security' | 'device' | 'data' | 'system';
level: 'info' | 'warn' | 'error' | 'security';
targetType: string | null;
targetId: string | null;
targetUserEmail?: string | null;
metadata: string | null;
createdAt: string;
}
// Cipher types
export enum CipherType {
Login = 1,
@@ -133,6 +206,7 @@ export interface Cipher {
key: string | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any;
@@ -147,6 +221,159 @@ export interface Folder {
updatedAt: string;
}
export interface Device {
userId: string;
deviceIdentifier: string;
name: string;
deviceNote: string | null;
type: number;
sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential {
id: string;
userId: string;
name: string;
publicKey: string;
credentialId: string;
counter: number;
type: string | null;
aaGuid: string | null;
transports: string[] | null;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
supportsPrf: boolean;
createdAt: string;
updatedAt: string;
}
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
export interface AccountPasskeyChallenge {
challengeHash: string;
scope: AccountPasskeyChallengeScope;
userId: string | null;
expiresAt: number;
usedAt: number | null;
createdAt: number;
}
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
object: string;
[key: string]: any;
}
export interface ProtectedDeviceResponse {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
object: string;
[key: string]: any;
}
export interface RefreshTokenRecord {
userId: string;
expiresAt: number;
deviceIdentifier: string | null;
deviceSessionStamp: string | null;
}
export interface TrustedDeviceTokenSummary {
deviceIdentifier: string;
expiresAt: number;
tokenCount: number;
}
export enum SendType {
Text = 0,
File = 1,
}
export enum SendAuthType {
Email = 0,
Password = 1,
None = 2,
}
export interface Send {
id: string;
userId: string;
type: SendType;
name: string;
notes: string | null;
data: string;
key: string;
passwordHash: string | null;
passwordSalt: string | null;
passwordIterations: number | null;
authType: SendAuthType;
emails: string | null;
maxAccessCount: number | null;
accessCount: number;
disabled: boolean;
hideEmail: boolean | null;
createdAt: string;
updatedAt: string;
expirationDate: string | null;
deletionDate: string;
}
export interface SendResponse {
id: string;
accessId: string;
type: number;
name: string;
notes: string | null;
text: any | null;
file: any | null;
key: string;
maxAccessCount: number | null;
accessCount: number;
password: string | null;
emails: string | null;
authType: SendAuthType;
disabled: boolean;
hideEmail: boolean | null;
revisionDate: string;
expirationDate: string | null;
deletionDate: string;
object: string;
}
// JWT Payload
export interface JWTPayload {
sub: string; // user id
@@ -155,6 +382,8 @@ export interface JWTPayload {
email_verified: boolean; // required by mobile client
amr: string[]; // authentication methods reference - required by mobile client
sstamp: string; // security stamp - invalidates token when user changes password
did?: string; // device identifier - invalidates per-device sessions
dstamp?: string; // device session stamp
iat: number;
exp: number;
iss: string;
@@ -177,11 +406,22 @@ export interface MasterPasswordUnlock {
Object: string;
}
export interface WebAuthnPrfDecryptionOption {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
Object?: string;
}
export interface UserDecryptionOptions {
HasMasterPassword: boolean;
Object: string;
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null;
KeyConnectorOption: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
}
// API Response types
@@ -189,7 +429,9 @@ export interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
refresh_token: string;
refresh_token?: string;
web_session?: boolean;
TwoFactorToken?: string;
Key: string;
PrivateKey: string | null;
Kdf: number;
@@ -200,7 +442,18 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
MasterPasswordPolicy?: {
Object: string;
} | null;
ApiUseKeyConnector?: boolean;
AccountKeys?: any | null;
accountKeys?: any | null;
UserDecryptionOptions: UserDecryptionOptions;
userDecryptionOptions?: UserDecryptionOptions;
VaultKeys?: {
symEncKey: string;
symMacKey: string;
};
}
export interface ProfileResponse {
@@ -224,6 +477,9 @@ export interface ProfileResponse {
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
verifyDevices?: boolean;
role?: UserRole;
status?: UserStatus;
object: string;
}
@@ -269,6 +525,7 @@ export interface FolderResponse {
id: string;
name: string;
revisionDate: string;
creationDate: string;
object: string;
}
@@ -279,7 +536,15 @@ export interface SyncResponse {
ciphers: CipherResponse[];
domains: any;
policies: any[];
sends: any[];
sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
Object?: string;
} | null;
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+269
View File
@@ -0,0 +1,269 @@
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
WebAuthnCredential,
} from '@simplewebauthn/server';
import type {
AccountPasskeyChallengeScope,
AccountPasskeyCredential,
AccountPasskeyPrfStatus,
Env,
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000;
const DEFAULT_RP_NAME = 'NodeWarden';
interface AccountPasskeyTokenPayload {
typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE;
scope: AccountPasskeyChallengeScope;
challenge: string;
userId: string | null;
rpId: string;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function sha256Base64Url(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', textBytes(value));
return bytesToBase64Url(new Uint8Array(digest));
}
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
}
export async function createAccountPasskeyToken(
env: Env,
input: {
scope: AccountPasskeyChallengeScope;
challenge: string;
userId?: string | null;
rpId: string;
ttlMs?: number;
}
): Promise<string> {
const now = Date.now();
const payload: AccountPasskeyTokenPayload = {
typ: ACCOUNT_PASSKEY_TOKEN_TYPE,
scope: input.scope,
challenge: input.challenge,
userId: input.userId ?? null,
rpId: input.rpId,
iat: now,
exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)),
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyAccountPasskeyToken(
env: Env,
token: string,
scope: AccountPasskeyChallengeScope
): Promise<AccountPasskeyTokenPayload | null> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return null;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return null;
const payload = decodeJson<AccountPasskeyTokenPayload>(parts[1]);
if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null;
if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null;
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } {
const url = new URL(request.url);
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) };
}
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return textBytes(userId);
}
export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null;
try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
return decoded.trim() || null;
} catch {
return null;
}
}
export function accountPasskeyPrfStatus(credential: Pick<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): AccountPasskeyPrfStatus {
if (!credential.supportsPrf) return 2;
if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0;
return 1;
}
export function buildWebAuthnPrfOption(
credential: AccountPasskeyCredential
): WebAuthnPrfDecryptionOption | null {
if (accountPasskeyPrfStatus(credential) !== 0) return null;
return {
EncryptedPrivateKey: credential.encryptedPrivateKey!,
EncryptedUserKey: credential.encryptedUserKey!,
CredentialId: credential.credentialId,
Transports: credential.transports || [],
Object: 'webAuthnPrfDecryptionOption',
};
}
export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record<string, unknown> {
const prfStatus = accountPasskeyPrfStatus(credential);
return {
Id: credential.id,
id: credential.id,
Name: credential.name,
name: credential.name,
PrfStatus: prfStatus,
prfStatus,
EncryptedPublicKey: credential.encryptedPublicKey,
encryptedPublicKey: credential.encryptedPublicKey,
EncryptedUserKey: credential.encryptedUserKey,
encryptedUserKey: credential.encryptedUserKey,
CreationDate: credential.createdAt,
RevisionDate: credential.updatedAt,
Object: 'webauthnCredential',
object: 'webauthnCredential',
};
}
export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential {
return {
id: credential.credentialId,
publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)),
counter: credential.counter,
transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined,
};
}
export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
attestationObject: String(response.attestationObject),
clientDataJSON: String(clientDataJSON),
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? String(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
},
};
}
export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null {
const input = raw && typeof raw === 'object' ? raw as Record<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : null;
if (!input || !response) return null;
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return {
id: String(input.id),
rawId: String(input.rawId),
type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: {
authenticatorData: String(response.authenticatorData),
clientDataJSON: String(clientDataJSON),
signature: String(response.signature),
userHandle: response.userHandle ? String(response.userHandle) : undefined,
},
};
}
export function normalizeAccountPasskeyName(value: unknown): string {
const normalized = String(value || '').trim();
return (normalized || 'Account passkey').slice(0, 128);
}
export function normalizeTransports(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const transports = value.map((item) => String(item || '').trim()).filter(Boolean);
return transports.length ? transports.slice(0, 12) : null;
}
export function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
+78
View File
@@ -0,0 +1,78 @@
const DEFAULT_DEVICE_NAME = 'Unknown device';
const DEFAULT_DEVICE_TYPE = 14;
function decodeBase64UrlUtf8(value: string): string | null {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
if (!value) return null;
const normalized = String(value).trim();
if (!normalized) return null;
return normalized.slice(0, 128);
}
function normalizeDeviceName(value: string | undefined | null): string {
const normalized = String(value || '').trim();
if (!normalized) return DEFAULT_DEVICE_NAME;
return normalized.slice(0, 128);
}
function parseDeviceType(value: string | number | undefined | null): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.floor(value));
}
const parsed = Number.parseInt(String(value || ''), 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return DEFAULT_DEVICE_TYPE;
}
export interface AuthRequestDeviceInfo {
deviceIdentifier: string | null;
deviceName: string;
deviceType: number;
}
export function readAuthRequestDeviceInfo(
body: Record<string, string | undefined>,
request: Request
): AuthRequestDeviceInfo {
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
const bodyName = body.deviceName || body.device_name;
const headerName = request.headers.get('X-Device-Name') || undefined;
const bodyType = body.deviceType || body.device_type;
const headerType = request.headers.get('Device-Type') || undefined;
return {
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
deviceName: normalizeDeviceName(bodyName || headerName),
deviceType: parseDeviceType(bodyType || headerType),
};
}
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
const encodedEmail = request.headers.get('X-Request-Email') || '';
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
const fallbackRawEmail = request.headers.get('X-Request-Email');
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
return { email, deviceIdentifier };
}
export function readActingDeviceIdentifier(request: Request): string | null {
return normalizeDeviceIdentifier(request.headers.get('X-NodeWarden-Acting-Device-Id'));
}
+104
View File
@@ -0,0 +1,104 @@
import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types';
import { errorResponse } from './response';
export interface DirectUploadPayload {
body: ReadableStream;
contentType: string;
size: number;
}
interface ParseDirectUploadOptions {
expectedSize?: number | null;
expectedFileName?: string | null;
maxFileSize: number;
tooLargeMessage: string;
missingBodyMessage?: string;
contentLengthRequiredMessage?: string;
sizeMismatchMessage?: string;
fileNameMismatchMessage?: string;
}
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z';
const origin = new URL(request.url).origin;
return `${origin}${path}?sv=${encodeURIComponent(version)}&se=${encodeURIComponent(expiresAt)}&token=${encodeURIComponent(token)}`;
}
export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return null;
}
return secret;
}
function parseContentLength(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);
}
export async function parseDirectUploadPayload(
request: Request,
options: ParseDirectUploadOptions
): Promise<DirectUploadPayload | Response> {
const {
expectedSize = null,
expectedFileName = null,
maxFileSize,
tooLargeMessage,
missingBodyMessage = 'No file uploaded',
contentLengthRequiredMessage = 'Content-Length is required for direct uploads',
sizeMismatchMessage,
fileNameMismatchMessage,
} = options;
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
const file = formData.get('data') as File | null;
if (!file) {
return errorResponse(missingBodyMessage, 400);
}
if (file.size > maxFileSize) {
return errorResponse(tooLargeMessage, 413);
}
if (expectedFileName && file.name !== expectedFileName) {
return errorResponse(fileNameMismatchMessage || 'File name does not match.', 400);
}
if (expectedSize !== null && expectedSize !== undefined && file.size !== expectedSize) {
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
}
return {
body: file.stream(),
contentType: file.type || 'application/octet-stream',
size: file.size,
};
}
if (!request.body) {
return errorResponse(missingBodyMessage, 400);
}
const declaredSize = parseContentLength(request);
const uploadSize = declaredSize ?? (expectedSize && expectedSize > 0 ? expectedSize : null);
if (uploadSize === null) {
return errorResponse(contentLengthRequiredMessage, 400);
}
if (uploadSize > maxFileSize) {
return errorResponse(tooLargeMessage, 413);
}
if (expectedSize !== null && expectedSize !== undefined && uploadSize !== expectedSize) {
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
}
return {
body: request.body,
contentType: contentType || 'application/octet-stream',
size: uploadSize,
};
}
+271 -28
View File
@@ -1,6 +1,8 @@
import { JWTPayload } from '../types';
import { LIMITS } from '../config/limits';
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
// Base64 URL encode
function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
@@ -19,6 +21,23 @@ function base64UrlDecode(str: string): Uint8Array {
return bytes;
}
function getHmacKey(secret: string): Promise<CryptoKey> {
const cacheKey = secret;
let cached = hmacKeyCache.get(cacheKey);
if (cached) return cached;
const encoder = new TextEncoder();
cached = crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
hmacKeyCache.set(cacheKey, cached);
return cached;
}
// Create JWT
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
@@ -40,13 +59,7 @@ export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss'
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -63,13 +76,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -104,6 +111,13 @@ export interface FileDownloadClaims {
exp: number;
}
export interface AttachmentUploadClaims {
userId: string;
cipherId: string;
attachmentId: string;
exp: number;
}
// Create file download token (short-lived, 5 minutes)
export async function createFileDownloadToken(
cipherId: string,
@@ -126,13 +140,7 @@ export async function createFileDownloadToken(
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -152,13 +160,7 @@ export async function verifyFileDownloadToken(
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -177,3 +179,244 @@ export async function verifyFileDownloadToken(
return null;
}
}
export async function createAttachmentUploadToken(
userId: string,
cipherId: string,
attachmentId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: AttachmentUploadClaims = {
userId,
cipherId,
attachmentId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifyAttachmentUploadToken(
token: string,
secret: string
): Promise<AttachmentUploadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null;
return payload;
} catch {
return null;
}
}
export interface SendFileDownloadClaims {
sendId: string;
fileId: string;
jti: string;
exp: number;
}
export interface SendFileUploadClaims {
userId: string;
sendId: string;
fileId: string;
exp: number;
}
export async function createSendFileDownloadToken(
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileDownloadClaims = {
sendId,
fileId,
jti: createRefreshToken(),
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendFileDownloadToken(
token: string,
secret: string
): Promise<SendFileDownloadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
if (
typeof payload.sendId !== 'string' ||
typeof payload.fileId !== 'string' ||
typeof payload.jti !== 'string' ||
!payload.jti ||
typeof payload.exp !== 'number'
) {
return null;
}
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
export async function createSendFileUploadToken(
userId: string,
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileUploadClaims = {
userId,
sendId,
fileId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendFileUploadToken(
token: string,
secret: string
): Promise<SendFileUploadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendFileUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.userId || !payload.sendId || !payload.fileId) return null;
return payload;
} catch {
return null;
}
}
export interface SendAccessTokenClaims {
sub: string; // send id
typ: 'send_access';
iat: number;
exp: number;
}
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendAccessTokenClaims = {
sub: sendId,
typ: 'send_access',
iat: now,
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (payload.typ !== 'send_access') return null;
if (!payload.sub) return null;
return payload;
} catch {
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlToBytes(input: string): Uint8Array {
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export function randomChallenge(size: number = 32): string {
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(size)));
}
export function parseClientDataJSON(base64Url: string): { type?: string; challenge?: string; origin?: string } | null {
try {
const raw = base64UrlToBytes(base64Url);
const text = new TextDecoder().decode(raw);
const parsed = JSON.parse(text) as { type?: string; challenge?: string; origin?: string };
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const RECOVERY_ALPHABET_LENGTH = RECOVERY_ALPHABET.length;
const RECOVERY_MAX_UNBIASED_BYTE = Math.floor(256 / RECOVERY_ALPHABET_LENGTH) * RECOVERY_ALPHABET_LENGTH;
function normalizeRecoveryCode(raw: string): string {
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function formatRecoveryCode(compact: string): string {
return compact.replace(/(.{4})/g, '$1 ').trim();
}
export function createRecoveryCode(): string {
let compact = '';
while (compact.length < 32) {
const bytes = crypto.getRandomValues(new Uint8Array(32));
for (const b of bytes) {
if (b >= RECOVERY_MAX_UNBIASED_BYTE) continue;
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET_LENGTH];
if (compact.length >= 32) break;
}
}
return formatRecoveryCode(compact.slice(0, 32));
}
export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean {
if (!storedCode) return false;
const a = new TextEncoder().encode(normalizeRecoveryCode(input));
const b = new TextEncoder().encode(normalizeRecoveryCode(storedCode));
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
+74 -31
View File
@@ -1,41 +1,81 @@
import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
const DEFAULT_CORS_HEADERS = [
'Content-Type',
'Authorization',
'Accept',
'Device-Type',
'Device-Identifier',
'Device-Name',
'Bitwarden-Client-Name',
'Bitwarden-Client-Version',
'Bitwarden-Package-Type',
'Is-Prerelease',
'X-Request-Email',
'X-Device-Identifier',
'X-Device-Name',
'X-NodeWarden-Web-Session',
];
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
if (origin.startsWith('chrome-extension://')) return true;
if (origin.startsWith('moz-extension://')) return true;
if (origin.startsWith('safari-web-extension://')) return true;
if (origin.startsWith('app://')) return true;
if (origin.startsWith('capacitor://')) return true;
if (origin.startsWith('ionic://')) return true;
return false;
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function getAllowedOrigin(request: Request): string | null {
const origin = request.headers.get('Origin');
if (!origin) return null;
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
|| path === '/config'
|| path === '/api/config'
|| path === '/api/version'
);
}
const targetOrigin = new URL(request.url).origin;
if (origin === targetOrigin) return origin;
if (isTrustedClientOrigin(origin)) return origin;
return null;
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
if (!origin) {
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
return { allowOrigin: '*', allowCredentials: false };
}
return { allowOrigin: null, allowCredentials: false };
}
function buildCorsHeaders(request: Request): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const allowHeaders = Array.from(new Set([...DEFAULT_CORS_HEADERS, ...requestedHeaders]));
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': CORS_METHODS,
'Access-Control-Allow-Headers': CORS_HEADERS,
'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Expose-Headers': '*',
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const allowedOrigin = getAllowedOrigin(request);
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin;
headers['Vary'] = 'Origin';
const corsPolicy = getCorsPolicy(request);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
}
return headers;
@@ -45,11 +85,22 @@ export function applyCors(
request: Request,
response: Response
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
if (response.status === 101 || webSocket) {
return response;
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
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:");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
@@ -100,14 +151,6 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
// Handle CORS preflight
export function handleCors(request: Request): Response {
const origin = request.headers.get('Origin');
if (origin) {
const allowedOrigin = getAllowedOrigin(request);
if (!allowedOrigin) {
return new Response(null, { status: 403 });
}
}
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
+98
View File
@@ -0,0 +1,98 @@
const TOTP_STEP_SECONDS = 30;
const TOTP_DIGITS = 6;
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
function normalizeBase32(input: string): string {
const raw = String(input || '').toUpperCase();
let out = '';
for (const char of raw) {
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
out += char;
}
while (out.endsWith('=')) {
out = out.slice(0, -1);
}
return out;
}
function base32Decode(input: string): Uint8Array | null {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalized = normalizeBase32(input);
if (!normalized) return null;
let bits = 0;
let value = 0;
const output: number[] = [];
for (const char of normalized) {
const idx = alphabet.indexOf(char);
if (idx === -1) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
output.push((value >> bits) & 0xff);
}
}
return output.length > 0 ? new Uint8Array(output) : null;
}
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
const counterBytes = new Uint8Array(8);
let c = counter;
for (let i = 7; i >= 0; i--) {
counterBytes[i] = c & 0xff;
c = Math.floor(c / 256);
}
const key = await crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
const offset = signature[signature.length - 1] & 0x0f;
const binary =
((signature[offset] & 0x7f) << 24) |
((signature[offset + 1] & 0xff) << 16) |
((signature[offset + 2] & 0xff) << 8) |
(signature[offset + 3] & 0xff);
const otp = binary % (10 ** TOTP_DIGITS);
return otp.toString().padStart(TOTP_DIGITS, '0');
}
function normalizeToken(token: string): string {
return token.replace(/\s+/g, '');
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false;
const secret = base32Decode(secretRaw);
if (!secret) return false;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta);
// Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token);
let diff = a.length ^ b.length;
for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
}
if (diff === 0) matched = true;
}
return matched;
}
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
}
+72
View File
@@ -0,0 +1,72 @@
import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types';
function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return '';
return String(value);
}
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
if (!user.privateKey) {
return null;
}
const publicKey = normalizeOptionalPublicKey(user.publicKey);
return {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey,
publicKey,
Object: 'publicKeyEncryptionKeyPair',
},
Object: 'privateKeys',
};
}
export function buildMasterPasswordUnlock(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
): UserDecryptionOptions['MasterPasswordUnlock'] {
return {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory ?? null,
Parallelism: user.kdfParallelism ?? null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
};
}
export function buildUserDecryptionOptions(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
): UserDecryptionOptions {
return {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOption,
};
}
export function buildUserDecryptionCompat(
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
): Record<string, unknown> {
return {
masterPasswordUnlock: {
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory ?? null,
parallelism: user.kdfParallelism ?? null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email.toLowerCase(),
},
};
}
+33
View File
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
canvas: 'var(--bg-accent)',
panel: 'var(--panel)',
'panel-soft': 'var(--panel-soft)',
'panel-muted': 'var(--panel-muted)',
line: 'var(--line)',
'line-soft': 'var(--line-soft)',
ink: 'var(--text)',
muted: 'var(--muted)',
'muted-strong': 'var(--muted-strong)',
brand: 'var(--primary)',
'brand-hover': 'var(--primary-hover)',
'brand-strong': 'var(--primary-strong)',
danger: 'var(--danger)',
},
boxShadow: {
soft: 'var(--shadow-sm)',
panel: 'var(--shadow-md)',
elevated: 'var(--shadow-lg)',
},
fontFamily: {
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
},
},
},
plugins: [],
};
+1 -1
View File
@@ -15,6 +15,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"include": ["src/**/*", "shared/**/*"],
"exclude": ["node_modules"]
}
+100
View File
@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
form-action 'self';
base-uri 'self';
" />
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>NodeWarden</title>
<style>
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: #eef4ff;
color: #0f172a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.boot-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.boot-card {
width: min(420px, 100%);
display: grid;
gap: 14px;
justify-items: center;
padding: 28px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
}
.boot-logo {
width: 74px;
height: 58px;
object-fit: contain;
}
.boot-line {
width: 72%;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
background-size: 180% 100%;
animation: boot-shimmer 1.2s ease-in-out infinite;
}
.boot-line.short {
width: 46%;
}
@keyframes boot-shimmer {
0% { background-position: 180% 0; }
100% { background-position: -180% 0; }
}
</style>
</head>
<body>
<div id="root">
<div class="boot-screen">
<div class="boot-card" aria-label="Loading NodeWarden">
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
<div class="boot-line"></div>
<div class="boot-line short"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

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