516 Commits

Author SHA1 Message Date
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 d1e6ec8b8d fix: update version to 1.0.0 in package.json and package-lock.json 2026-02-19 22:14:44 +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 3e56d05283 chore: remove temporary subproject references for cleanup 2026-02-19 21:39:12 +08:00
shuaiplus 2f7dbc78d3 chore: remove temporary subproject references for cleanup 2026-02-19 21:39:12 +08:00
shuaiplus 870149c771 style: enhance register page styling with grid background and button effects 2026-02-19 21:13:59 +08:00
shuaiplus 1a22b108ca style: enhance register page styling with grid background and button effects 2026-02-19 21:13:59 +08:00
shuaiplus 9771df8777 fix: update bitwarden server version to 2026.1.0 2026-02-19 19:58:33 +08:00
shuaiplus 40549147bd fix: update bitwarden server version to 2026.1.0 2026-02-19 19:58:33 +08:00
shuaiplus 0be3b91dd7 Refactor code structure for improved readability and maintainability 2026-02-19 18:57:23 +08:00
shuaiplus c0a390baa5 Refactor code structure for improved readability and maintainability 2026-02-19 18:57:23 +08:00
shuaiplus 645a2f8e95 docs: add Star History section to README files 2026-02-19 16:08:08 +08:00
shuaiplus 7cdccde684 docs: add Star History section to README files 2026-02-19 16:08:08 +08:00
shuaiplus f63b5d6cf4 feat(storage): add method to retrieve attachments by user ID for improved data handling 2026-02-19 02:27:56 +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 081dc64093 fix(storage): optimize attachment retrieval by batching cipher IDs to improve performance 2026-02-19 01:42:55 +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 3ec1ecf464 docs: update feature comparison table in README files for clarity and consistency 2026-02-18 21:29:51 +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 b6d4113e21 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 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 c53819e178 fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting 2026-02-18 03:06:50 +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 fff2b149e9 fix: enhance cipher handling to support unknown fields and improve database binding 2026-02-17 22:20:01 +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 50ee2e6b64 fix: adjust layout and improve JWT_SECRET instructions on registration page 2026-02-15 03:10:59 +08:00
shuaiplus 72ec21415b fix: adjust layout and improve JWT_SECRET instructions on registration page 2026-02-15 03:10:59 +08:00
shuaiplus 309cd98edc fix: update README to clarify deployment steps and features 2026-02-15 02:56:31 +08:00
shuaiplus 649f54f923 fix: update README to clarify deployment steps and features 2026-02-15 02:56:31 +08:00
shuaiplus 3fff6c0277 Refactor code structure for improved readability and maintainability 2026-02-15 02:45:57 +08:00
shuaiplus beefe2227e Refactor code structure for improved readability and maintainability 2026-02-15 02:45:57 +08:00
shuaiplus ced0a183b3 fix: remove placeholder database_id from D1 database configuration 2026-02-15 02:25:26 +08:00
shuaiplus 326e13adf0 fix: remove placeholder database_id from D1 database configuration 2026-02-15 02:25:26 +08:00
shuaiplus 4939df7fa2 fix: correct link to English README in Chinese version 2026-02-15 02:23:01 +08:00
shuaiplus 6e1a8e7b5c fix: correct link to English README in Chinese version 2026-02-15 02:23:01 +08:00
shuaiplus 6c3fbbe78c Refactor code structure for improved readability and maintainability 2026-02-15 02:21:55 +08:00
shuaiplus c5d3052080 Refactor code structure for improved readability and maintainability 2026-02-15 02:21:55 +08:00
shuaiplus 719024d0fd feat: enhance rate limiting by tracking login attempts per client IP and refining API rate limits for write operations 2026-02-14 20:56:34 +08:00
shuaiplus ff7b44e501 feat: update setup pages and router to enhance UI and favicon handling 2026-02-14 01:03:40 +08:00
shuaiplus 4772c17e44 feat: enhance user registration and authentication flow, improve attachment handling, and strengthen security measures 2026-02-14 00:34:08 +08:00
shuaiplus b33ee64c58 Improve API response formatting and structure in handlers 2026-02-11 23:53:36 +08:00
shuaiplus c825280707 fix: allow nullable name fields in user and cipher interfaces for better flexibility 2026-02-11 21:38:54 +08:00
shuaiplus c445714fd5 refactor: update user decryption structure in sync response for consistency 2026-02-10 22:07:34 +08:00
shuaiplus f2a857d3f3 fix: update database schema to ensure consistency between SQL initialization and application code 2026-02-09 23:36:59 +08:00
shuaiplus 435a21072c fix: update deployment link to point to the d1-test branch 2026-02-09 23:05:08 +08:00
shuaiplus 70a58aeb04 feat: implement database initialization in StorageService 2026-02-09 23:00:23 +08:00
shuaiplus 866ffb8390 fix: correct syntax error in database_id in wrangler.toml 2026-02-09 22:06:50 +08:00
shuaiplus d2ce2aea24 chore: switch storage to D1 (test branch) 2026-02-09 22:00:14 +08:00
shuaiplus 5fc2436552 Refactor JWT_SECRET handling and add setup warning page 2026-02-08 21:27:13 +08:00
220 changed files with 62078 additions and 2453 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
JWT_SECRET=your-secret-key-herexxs22fd2ds
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+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
+143
View File
@@ -0,0 +1,143 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
echo "mode=auto" >> $GITHUB_OUTPUT
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
fi
+14 -1
View File
@@ -6,6 +6,8 @@ node_modules/
.dev.vars
wrangler.my.toml
RELEASE_NOTES.md
tests/selfcheck.ts
problem.md
# Build output
dist/
@@ -24,7 +26,7 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
.vite-tailwind.err
# Environment
.env
.env.local
@@ -35,3 +37,14 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it)
# package-lock.json
tmp/
.tmp/
nodewarden.wiki/
wiki/
AGENTS.md
settings.json
.claude/
NodeWarden-compat/
.codex-upstream/
+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

+120 -64
View File
@@ -1,108 +1,164 @@
# NodeWarden
中文文档:[`README_ZH.md`](./README_ZH.md)
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use.
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
- Simple deploy (no VPS)
- Focused feature set
- Low maintenance
<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>
> Disclaimer
> - This project is **not affiliated** with Bitwarden.
> - Use at your own risk. Keep regular backups of your vault.
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## Features
## 与 Bitwarden 官方服务端能力对比
-**Free to use. No server to manage.**
- ✅ Full support for logins, notes, cards, and identities
- ✅ Folders and favorites
- ✅ Attachments (Cloudflare R2)
- ✅ Import / export
- ✅ Website icons
- ✅ End-to-end encryption (the server cant see plaintext)
- ✅ Compatible with common Bitwarden official clients
## Tested clients / platforms
- ✅ Windows desktop clientv2026.1.0
- ✅ Android app v2026.1.0
- ✅ Browser extensionv2026.1.0
- ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested)
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
# Quick start
## 已测试客户端
### One-click deploy
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
---
**Deploy steps:**
## 可视化快速部署
1. Sign in with GitHub and authorize
2. Sign in to Cloudflare
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
4. KV namespace and R2 bucket will be created automatically
5. Click **Deploy** and wait for it to finish
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
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 域名
> ⚠️ **Reminder**: always use a strong random `JWT_SECRET`. Weak secrets may put your account at risk.
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
### Configure your client
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
In any Bitwarden client:
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
1. Open **Settings**
2. Choose **Self-hosted environment**
3. Set **Server URL** to your Worker URL (for example: `https://your-project.your-subdomain.workers.dev`)
4. Save, then go back to the login screen
## 🧑‍💻 Local development
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
This repo is a Cloudflare Workers TypeScript project (Wrangler).
```bash
## 更新方法:
- 手动:打开你 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
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## Tech stack
## 云端备份说明
- **Runtime**: Cloudflare Workers
- **Data storage**: Cloudflare KV
- **File storage**: Cloudflare R2
- **Language**: TypeScript
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## FAQ
## 导入 / 导出
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
当前支持的导入来源包括:
**Q: What if I forget the master password?**
A: It cant be recovered (end-to-end encryption). Keep it safe.
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
**Q: Can multiple people use it?**
A: Not recommended. This project is designed for single-user usage.
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## License
## 开源协议
LGPL-3.0 License
---
## Credits
## 致谢
- [Bitwarden](https://bitwarden.com/) - original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
- [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)
+152
View File
@@ -0,0 +1,152 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Cloud Backup Notes
- Remote backup supports **WebDAV** and **E3**
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
-114
View File
@@ -1,114 +0,0 @@
# NodeWarden
English[`README.md`](./README.md)
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景。
- 部署简单(不需要 VPS
- 功能聚焦
- 维护成本低
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
---
## 特性
-**完全免费,不需要在服务器上部署,再次感谢大善人!**
- ✅ 完整的密码、笔记、卡片、身份信息管理
- ✅ 文件夹和收藏功能
- ✅ 文件附件支持(基于 R2 存储)
- ✅ 导入/导出功能
- ✅ 网站图标获取
- ✅ 端到端加密(服务器无法查看明文)
- ✅ 兼容常见的 Bitwarden 官方客户端
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ Android Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
---
# 快速开始
### 一键部署
点击下方按钮部署到 Cloudflare Workers
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
**部署步骤:**
1. 使用 GitHub 登录并授权
2. 登录 Cloudflare 账户
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
4. KV 存储和 R2 存储桶将自动创建
5. 点击 Deploy 等待部署完成
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!**
### 配置客户端
部署完成后,在任意 Bitwarden 客户端中:
1. 打开设置(⚙️
2. 选择「自托管环境」
3. 服务器 URL 填入:`https://你的项目名`
4. 保存并返回登录页面
---
## 本地开发
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
```bash
npm install
npm run dev
```
---
## 技术栈
- **运行环境**Cloudflare Workers
- **数据存储**Cloudflare KV
- **文件存储**Cloudflare R2
- **开发语言**TypeScript
- **加密算法**:客户端 AES-256-CBCJWT 使用 HS256
---
## 常见问题
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
**Q: 可以多人使用吗?**
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
---
## 开源协议
LGPL-3.0 License
---
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
+212
View File
@@ -0,0 +1,212 @@
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
);
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
);
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,
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
);
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);
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);
-- Rate limiting
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
);
+2849 -163
View File
File diff suppressed because it is too large Load Diff
+38 -10
View File
@@ -1,15 +1,23 @@
{
"name": "nodewarden",
"version": "0.1.0",
"version": "1.5.2",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "wrangler dev -c wrangler.dev.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy "
"dev": "npm run build && wrangler dev -c wrangler.toml",
"dev:kv": "npm run build && 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": "npm run build && wrangler deploy",
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
},
"keywords": [
"bitwarden",
@@ -21,20 +29,40 @@
"cloudflare": {
"bindings": {
"JWT_SECRET": {
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
"description": "Use a strong random string (32+ characters recommended)"
},
"VAULT": {
"description": "用于存储密码库数据的 KV 存储"
"DB": {
"description": "D1 database for storing vault data"
},
"ATTACHMENTS": {
"description": "用于存储文件附件的 R2 存储桶"
"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",
"@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: {},
},
};
+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.5.2';
+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);
}
+157
View File
@@ -0,0 +1,157 @@
export const LIMITS = {
auth: {
// Access token lifetime in seconds.
// 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 365 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 30 * 60 * 1000,
// Refresh 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: 10,
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// 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,
// Probability to run low-frequency cleanup on request path.
// 在请求路径中触发低频清理的概率。
cleanupProbability: 0.05,
// Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
},
cleanup: {
// Minimum interval between refresh-token cleanup runs.
// refresh_token 表清理最小间隔。
refreshTokenCleanupIntervalMs: 30 * 60 * 1000,
// Minimum interval between used attachment token cleanup runs.
// 已使用附件令牌表清理最小间隔。
attachmentTokenCleanupIntervalMs: 10 * 60 * 1000,
// Probability to trigger cleanup during requests.
// 请求过程中触发清理的概率。
cleanupProbability: 0.05,
},
attachment: {
// Max attachment upload size in bytes.
// 附件上传大小上限(字节)。
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 时的默认分页大小。
defaultPageSize: 100,
// Hard maximum page size accepted by server.
// 服务端允许的最大分页大小。
maxPageSize: 500,
},
cors: {
// Browser preflight cache max age in seconds.
// 浏览器预检请求缓存时长(秒)。
preflightMaxAgeSeconds: 86400,
},
cache: {
// Icon proxy cache TTL in seconds.
// 图标代理缓存时长(秒)。
iconTtlSeconds: 604800,
// 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,
},
performance: {
// 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.4.1',
// Advertise official per-cipher item-key encryption support only after
// NodeWarden can guarantee key/field consistency across all write paths.
// 在所有写入路径都能保证 cipher.key 与字段密文一致之前,不向官方客户端声明支持逐项密钥加密。
cipherKeyEncryptionFeatureEnabled: false,
},
} as const;
+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);
}
+781 -77
View File
@@ -1,29 +1,154 @@
import { Env, User, ProfileResponse } from '../types';
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption';
// POST /api/accounts/register (only used from setup page, not client)
// CONTRACT:
// users.master_password_hash is server-side login verification only. It does
// not decrypt vault data. Password changes must keep encrypted user key material,
// securityStamp, refresh-token invalidation, and client compatibility together.
// Password hints are non-secret reminders; never treat them as recovery secrets.
function looksLikeEncString(value: string): boolean {
if (!value) return false;
const firstDot = value.indexOf('.');
if (firstDot <= 0 || firstDot === value.length - 1) return false;
const payload = value.slice(firstDot + 1);
const parts = payload.split('|');
// Bitwarden encrypted payloads should have at least IV + ciphertext.
return parts.length >= 2;
}
/**
* Validate KDF parameters according to Bitwarden minimum requirements.
* Returns an error message if invalid, or null if OK.
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
return 'PBKDF2 iterations must be at least 100000';
}
} else if (type === 1) {
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
return 'Argon2id iterations must be at least 2';
}
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
return 'Argon2id memory must be at least 16 MiB';
}
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
return 'Argon2id parallelism must be at least 1';
}
}
return null;
}
function normalizeTotpSecret(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 normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
const normalized = String(input || '').trim();
return normalized ? normalized : null;
}
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;
}
async function verifyUserSecret(
auth: AuthService,
user: User,
secret: string | null | undefined
): Promise<boolean> {
const normalized = String(secret || '').trim();
if (!normalized) return false;
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function toProfile(user: User, env: Env): ProfileResponse {
void env;
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
};
}
// POST /api/accounts/register
// - First user becomes admin.
// - Any subsequent user must provide a valid inviteCode.
export async function handleRegister(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Check if already registered
const isRegistered = await storage.isRegistered();
if (isRegistered) {
return errorResponse('Registration is closed', 403);
const unsafe = jwtSecretUnsafeReason(env);
if (unsafe) {
const message = unsafe === 'missing'
? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400);
}
let body: {
email?: string;
name?: string;
masterPasswordHash?: string;
masterPasswordHint?: string;
key?: string;
kdf?: number;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
inviteCode?: string;
masterPasswordHint?: string;
keys?: {
publicKey?: string;
encryptedPrivateKey?: string;
@@ -36,110 +161,290 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invalid JSON', 400);
}
const email = body.email?.toLowerCase();
const name = body.name || email;
const email = body.email?.toLowerCase().trim();
const name = body.name?.trim() || email;
const masterPasswordHash = body.masterPasswordHash;
const key = body.key;
const privateKey = body.keys?.encryptedPrivateKey;
const publicKey = body.keys?.publicKey;
const inviteCode = (body.inviteCode || '').trim();
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (!email || !masterPasswordHash || !key) {
return errorResponse('Email, masterPasswordHash, and key are required', 400);
}
if (!email.includes('@') || email.length < 3) {
return errorResponse('Invalid email address', 400);
}
if (!privateKey || !publicKey) {
return errorResponse('Private key and public key are required', 400);
}
if (!looksLikeEncString(key)) {
return errorResponse('key is not a valid encrypted string', 400);
}
if (!looksLikeEncString(privateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
const now = new Date().toISOString();
const auth = new AuthService(env);
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
// Create user
const user: User = {
id: generateUUID(),
email: email,
email,
name: name || email,
masterPasswordHash: masterPasswordHash,
key: key,
privateKey: privateKey,
publicKey: publicKey,
masterPasswordHint,
masterPasswordHash: serverHash,
key,
privateKey,
publicKey,
kdfType: body.kdf ?? 0,
kdfIterations: body.kdfIterations ?? 600000,
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
kdfMemory: body.kdfMemory,
kdfParallelism: body.kdfParallelism,
securityStamp: generateUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
role: 'user',
status: 'active',
verifyDevices: true,
totpSecret: null,
totpRecoveryCode: null,
apiKey: null,
createdAt: now,
updatedAt: now,
};
await storage.saveUser(user);
await storage.setRegistered();
return jsonResponse({ success: true }, 200);
}
// GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
const userCount = await storage.getUserCount();
if (userCount === 0) {
user.role = 'admin';
const created = await storage.createFirstUser(user);
if (!created) {
return errorResponse('Registration is temporarily unavailable, retry once', 409);
}
await storage.setRegistered();
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.first_admin',
targetType: 'user',
targetId: user.id,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
}
const profile: ProfileResponse = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: false,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
object: 'profile',
};
return jsonResponse(profile);
}
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
if (!inviteCode) {
return errorResponse('Invite code is required', 403);
}
let body: { name?: string; masterPasswordHint?: string };
try {
await storage.createUser(user);
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
throw error;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
}
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.register.invite',
targetType: 'user',
targetId: user.id,
category: 'security',
level: 'info',
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
});
return jsonResponse({ success: true, role: user.role }, 200);
}
// POST /api/accounts/password-hint
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
let body: { email?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.name) {
user.name = body.name;
const email = String(body.email || '').trim().toLowerCase();
if (!email) {
return errorResponse('Email is required', 400);
}
const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute,
60
);
if (!minuteBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60
);
if (!hourlyBudget.allowed) {
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
'X-RateLimit-Remaining': '0',
},
}
);
}
const user = await storage.getUser(email);
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
return jsonResponse({
object: 'passwordHint',
hasHint: !!hint,
masterPasswordHint: hint,
});
}
// GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(toProfile(user, env));
}
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
masterPasswordHint?: string | null;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
if (masterPasswordHint && masterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHint = masterPasswordHint;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.profile.update',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
updatedMasterPasswordHint: true,
...auditRequestMetadata(request),
},
});
return handleGetProfile(request, env, userId);
return jsonResponse(toProfile(user, env));
}
// PUT/POST /api/accounts/verify-devices
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (typeof body.verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
// POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) {
@@ -147,6 +452,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
}
let body: {
masterPasswordHash?: string;
key?: string;
encryptedPrivateKey?: string;
publicKey?: string;
@@ -158,21 +464,336 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return errorResponse('Invalid JSON', 400);
}
// Require password verification before allowing key replacement.
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
if (body.key && !looksLikeEncString(body.key)) {
return errorResponse('key is not a valid encrypted string', 400);
}
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.keys.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
updatedKey: !!body.key,
updatedPrivateKey: !!body.encryptedPrivateKey,
updatedPublicKey: !!body.publicKey,
...auditRequestMetadata(request),
},
});
return handleGetProfile(request, env, userId);
}
// POST/PUT /api/accounts/password
export async function handleChangePassword(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
masterPasswordHash?: string;
currentPasswordHash?: string;
newMasterPasswordHash?: string;
key?: string;
newKey?: string;
encryptedPrivateKey?: string;
newEncryptedPrivateKey?: string;
publicKey?: string;
newPublicKey?: string;
kdf?: number;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = body.currentPasswordHash || body.masterPasswordHash;
if (!currentHash) return errorResponse('Current password hash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400);
}
const nextKey = body.newKey || body.key;
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) {
return errorResponse('new key is not a valid encrypted string', 400);
}
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
}
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism;
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
category: 'security',
level: 'security',
metadata: { email: user.email, ...auditRequestMetadata(request) },
});
return new Response(null, { status: 200 });
}
// GET /api/accounts/totp
export async function handleGetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse({
enabled: !!user.totpSecret,
object: 'twoFactor',
});
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
return errorResponse('TOTP token is required', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) {
return errorResponse('Invalid TOTP token', 400);
}
user.totpSecret = normalizedSecret;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
}
if (body.enabled === false) {
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required to disable TOTP', 400);
}
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
user.totpSecret = null;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.totp.disable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ enabled: false, object: 'twoFactor' });
}
return errorResponse('enabled must be true or false', 400);
}
// POST /api/accounts/totp/recovery-code
export async function handleGetTotpRecoveryCode(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
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 errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
code: user.totpRecoveryCode,
object: 'twoFactorRecover',
});
}
// POST /identity/accounts/recover-2fa
// Disable TOTP by recovery code + password, then rotate recovery code.
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
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 errorResponse('Invalid JSON', 400);
}
const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) {
return errorResponse(
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
429
);
}
if (!email || !masterPasswordHash || !recoveryCode) {
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
}
const user = await storage.getUser(email);
if (!user || user.status !== 'active') {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!validPassword) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'account.totp.recover',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({
success: true,
twoFactorEnabled: false,
newRecoveryCode: user.totpRecoveryCode,
object: 'twoFactorRecovery',
});
}
// GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
void request;
const storage = new StorageService(env.DB);
const revisionDate = await storage.getRevisionDate(userId);
// Return as milliseconds timestamp (Bitwarden format)
const timestamp = new Date(revisionDate).getTime();
return jsonResponse(timestamp);
@@ -180,7 +801,8 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
// POST /api/accounts/verify-password
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) {
@@ -198,9 +820,91 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('masterPasswordHash is required', 400);
}
if (body.masterPasswordHash !== user.masterPasswordHash) {
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return new Response(null, { status: 200 });
}
// POST /api/accounts/api-key
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, false);
}
// POST /api/accounts/rotate-api-key
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, true);
}
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
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 errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
category: 'security',
level: rotate ? 'security' : 'info',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
}
return jsonResponse({
apiKey: user.apiKey,
revisionDate: user.updatedAt,
object: 'apiKey',
});
}
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
const bytes = new Uint8Array(Math.max(16, length));
while (result.length < length) {
crypto.getRandomValues(bytes);
for (const value of bytes) {
if (value >= maxUnbiased) continue;
result += chars[value % chars.length];
if (result.length >= length) break;
}
}
return result;
}
+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 });
}
+261 -123
View File
@@ -1,8 +1,56 @@
import { Env, Attachment, Cipher } from '../types';
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 {
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 {
@@ -12,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
@@ -25,7 +129,7 @@ export async function handleCreateAttachment(
userId: string,
cipherId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -69,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
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
fileUploadType: 1,
cipherResponse: cipherToResponse(updatedCipher!, attachments),
});
}
// Maximum file size: 100MB
const MAX_FILE_SIZE = 100 * 1024 * 1024;
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
// Upload attachment file content
export async function handleUploadAttachment(
@@ -96,7 +205,7 @@ export async function handleUploadAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -110,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}
@@ -169,7 +269,7 @@ export async function handleGetAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -182,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);
@@ -192,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,
});
}
@@ -209,6 +368,11 @@ export async function handlePublicDownloadAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url);
const token = url.searchParams.get('token');
@@ -227,7 +391,7 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify attachment exists
const attachment = await storage.getAttachment(attachmentId);
@@ -235,20 +399,23 @@ 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);
}
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
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',
'Access-Control-Allow-Origin': '*',
},
});
}
@@ -262,7 +429,7 @@ export async function handleDeleteAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -276,84 +443,55 @@ 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);
const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({
cipher: formatCipherResponse(updatedCipher!, attachments),
cipher: cipherToResponse(updatedCipher!, attachments),
});
}
// Helper: Format cipher response with attachments
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
return {
id: cipher.id,
organizationId: null,
folderId: cipher.folderId,
type: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: attachments.length > 0 ? attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0,
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
object: 'attachment',
})) : null,
key: cipher.key,
encryptedFor: null,
};
}
// Delete all attachments for a cipher (used when deleting cipher)
export async function deleteAllAttachmentsForCipher(
env: Env,
cipherId: string
): Promise<void> {
const storage = new StorageService(env.VAULT);
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
+990 -99
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
));
}
+91 -7
View File
@@ -1,7 +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 {
@@ -9,25 +43,38 @@ function folderToResponse(folder: Folder): FolderResponse {
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
};
}
// GET /api/folders
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const folders = await storage.getAllFolders(userId);
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const pagination = parsePagination(url);
let folders: Folder[];
let continuationToken: string | null = null;
if (pagination) {
const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset);
const hasNext = pageRows.length > pagination.limit;
folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null;
} else {
folders = await storage.getAllFolders(userId);
}
return jsonResponse({
data: folders.map(folderToResponse),
object: 'list',
continuationToken: null,
continuationToken: continuationToken,
});
}
// GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
@@ -39,7 +86,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
// POST /api/folders
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
let body: { name?: string };
try {
@@ -62,13 +109,15 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
};
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200);
}
// PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
@@ -88,20 +137,55 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder));
}
// DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404);
}
await storage.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, 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 });
}
+670 -83
View File
@@ -1,43 +1,242 @@
import { Env, TokenResponse } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { RateLimitService } from '../services/ratelimit';
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';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function 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 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), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [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> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.VAULT);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
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();
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 identityErrorResponse('Invalid request payload', 'invalid_request', 400);
}
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 twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
return errorResponse('Email and password are required', 400);
// Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
}
const user = await storage.getUser(email);
if (!user) {
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
// Check if login is rate limited (only after confirming user exists)
const loginCheck = await rateLimit.checkLoginAttempt(email);
// 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.`,
@@ -46,117 +245,461 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
const user = await storage.getUser(email);
if (!user) {
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, user.email);
if (!valid) {
// Record failed login attempt
const result = await rateLimit.recordFailedLogin(email);
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_LEGACY) ||
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(email);
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: 7200,
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,
Salt: email, // email is already lowercased above
},
},
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
return jsonResponse(response);
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 errorResponse('Refresh token is required', 400);
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
if (!result) {
return errorResponse('Invalid refresh token', 401);
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: 7200,
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,
Salt: user.email.toLowerCase(),
},
},
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
return jsonResponse(response);
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
: baseResponse;
}
return errorResponse('Unsupported grant type', 400);
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
}
// POST /identity/accounts/prelogin
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
let body: { email?: string };
try {
@@ -174,14 +717,58 @@ 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 ?? 600000;
const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
// 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;
}
+167 -56
View File
@@ -1,21 +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 } 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;
@@ -56,6 +67,7 @@ interface CiphersImportRequest {
password: string;
lastUsedDate: string;
}> | null;
[key: string]: any;
}>;
folders: Array<{
name: string;
@@ -66,9 +78,32 @@ interface CiphersImportRequest {
}>;
}
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);
await db.batch(chunk);
}
}
// 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.VAULT);
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
let importData: CiphersImportRequest;
try {
@@ -81,10 +116,16 @@ 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;
// Create folders and build index -> id mapping
const folderIdMap = new Map<number, string>();
const folderRows: Folder[] = [];
for (let i = 0; i < folders.length; i++) {
const folderId = generateUUID();
@@ -98,7 +139,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
updatedAt: now,
};
await storage.saveFolder(folder);
folderRows.push(folder);
}
if (folderRows.length > 0) {
const folderStatements = folderRows.map(folder =>
env.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)
);
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
}
// Build cipher index -> folder id mapping from relationships
@@ -111,81 +164,139 @@ 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);
await storage.saveCipher(cipher);
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
}
if (cipherRows.length > 0) {
const cipherStatements = cipherRows.map(cipher => {
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, 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'
)
.bind(
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
bindNull(cipher.folderId),
bindNull(cipher.name),
bindNull(cipher.notes),
cipher.favorite ? 1 : 0,
data,
bindNull(cipher.reprompt ?? 0),
bindNull(cipher.key),
cipher.createdAt,
cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt)
);
});
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
}
// 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';
-795
View File
@@ -1,795 +0,0 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
// Setup page HTML (single-file, no external assets)
const setupPageHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeWarden</title>
<style>
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell {
width: max(500px);
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
}
.hero {
padding: 26px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
position: relative;
}
.hero::before {
content: "";
position: absolute;
inset: -2px;
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
pointer-events: none;
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title {
display: flex;
flex-direction: column;
gap: 4px;
}
.title h1 {
font-size: 22px;
margin: 0;
letter-spacing: -0.3px;
}
.title p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.panel h2 {
font-size: 16px;
margin: 0 0 14px 0;
letter-spacing: -0.2px;
}
.message {
display: none;
border-radius: 14px;
padding: 12px 12px;
margin: 0 0 12px 0;
font-size: 13px;
line-height: 1.45;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
}
.message.error {
display: block;
border-color: rgba(255, 69, 58, 0.40);
background: rgba(255, 69, 58, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.message.success {
display: block;
border-color: rgba(50, 215, 75, 0.35);
background: rgba(50, 215, 75, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
}
.field {
display: flex;
flex-direction: column;
gap: 7px;
}
label {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.2px;
}
input {
height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
outline: none;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input::placeholder { color: rgba(255,255,255,0.35); }
input:focus {
border-color: rgba(10, 132, 255, 0.55);
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
}
.hint {
margin: 0;
color: var(--muted2);
font-size: 12px;
line-height: 1.55;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
}
.primary {
width: 100%;
height: 44px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
color: rgba(255,255,255,0.96);
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.primary:hover { filter: brightness(1.03); }
.primary:active { transform: translateY(1px) scale(0.99); }
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.sideCard {
display: flex;
flex-direction: column;
gap: 12px;
}
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-bottom: 10px;
}
.kv h3 {
margin: 0 0 8px 0;
font-size: 13px;
color: rgba(255,255,255,0.86);
}
.kv p {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
}
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
a {
color: rgba(100, 210, 255, 0.92);
text-decoration: none;
}
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1 id="t_app">NodeWarden</h1>
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
</div>
</div>
<div style="height: 12px"></div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">初始化</h2>
<div id="message" class="message"></div>
<div id="setup-form">
<form id="form" onsubmit="handleSubmit(event)">
<div class="grid">
<div class="field">
<label for="name" id="t_name_label">Name</label>
<input type="text" id="name" name="name" required placeholder="Your name">
</div>
<div class="field">
<label for="email" id="t_email_label">Email</label>
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="password" id="t_pw_label">Master password</label>
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
</div>
<div class="actions">
<button type="submit" id="submitBtn" class="primary">Create account</button>
</div>
</form>
</div>
<div id="registered-view" class="sideCard" style="display: none;">
<div class="kv">
<h3 id="t_done_title">Setup complete</h3>
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
<div class="server" id="serverUrl"></div>
</div>
<div class="kv">
<h3 id="t_important">Important</h3>
<p id="t_limitations">
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
If you forget it, you must redeploy and register again.
</p>
</div>
<div class="kv">
<h3 id="t_hide_title">Hide setup page</h3>
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
<div class="actions">
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
</div>
</div>
</div>
<div class="footer">
<div>
<span class="muted" id="t_by">By</span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</aside>
</div>
<script>
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
let isRegistered = false;
function isChinese() {
const lang = (navigator.language || '').toLowerCase();
return lang.startsWith('zh');
}
function t(key) {
const zh = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
by: '作者',
setup: '初始化',
nameLabel: '昵称',
emailLabel: '邮箱',
pwLabel: '主密码',
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
pw2Label: '确认主密码',
create: '创建账号',
creating: '正在创建…',
doneTitle: '初始化完成',
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
important: '重要提示',
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
hideTitle: '隐藏初始化页',
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
hideBtn: '隐藏初始化页',
hideWorking: '正在隐藏…',
hideDone: '已隐藏,此页面将返回 404。',
hideFailed: '隐藏失败',
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
errPwNotMatch: '两次输入的密码不一致',
errPwTooShort: '密码长度至少 12 位',
errGeneric: '发生错误:',
errRegisterFailed: '注册失败',
};
const en = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
by: 'By',
setup: 'Setup',
nameLabel: 'Name',
emailLabel: 'Email',
pwLabel: 'Master password',
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
pw2Label: 'Confirm password',
create: 'Create account',
creating: 'Creating…',
doneTitle: 'Setup complete',
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
important: 'Important',
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
hideTitle: 'Hide setup page',
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
hideBtn: 'Hide setup page',
hideWorking: 'Hiding…',
hideDone: 'Hidden. This page will now return 404.',
hideFailed: 'Failed to hide setup page',
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
errPwNotMatch: 'Passwords do not match',
errPwTooShort: 'Password must be at least 12 characters',
errGeneric: 'An error occurred: ',
errRegisterFailed: 'Registration failed',
};
return (isChinese() ? zh : en)[key];
}
function applyI18n() {
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
document.getElementById('t_app').textContent = t('app');
document.getElementById('t_tag').textContent = t('tag');
document.getElementById('t_intro').textContent = t('intro');
document.getElementById('t_by').textContent = t('by');
document.getElementById('t_setup').textContent = t('setup');
document.getElementById('t_name_label').textContent = t('nameLabel');
document.getElementById('t_email_label').textContent = t('emailLabel');
document.getElementById('t_pw_label').textContent = t('pwLabel');
document.getElementById('t_pw_hint').textContent = t('pwHint');
document.getElementById('t_pw2_label').textContent = t('pw2Label');
document.getElementById('submitBtn').textContent = t('create');
document.getElementById('t_done_title').textContent = t('doneTitle');
document.getElementById('t_done_desc').textContent = t('doneDesc');
document.getElementById('t_important').textContent = t('important');
document.getElementById('t_limitations').textContent = t('limitations');
document.getElementById('t_hide_title').textContent = t('hideTitle');
document.getElementById('t_hide_desc').textContent = t('hideDesc');
document.getElementById('hideBtn').textContent = t('hideBtn');
}
// Check if already registered
async function checkStatus() {
try {
const res = await fetch('/setup/status');
const data = await res.json();
isRegistered = !!data.registered;
if (data.registered) {
showRegisteredView();
}
} catch (e) {
console.error('Failed to check status:', e);
}
}
function showRegisteredView() {
isRegistered = true;
document.getElementById('setup-form').style.display = 'none';
document.getElementById('registered-view').style.display = 'block';
document.getElementById('serverUrl').textContent = window.location.origin;
showMessage(t('doneTitle'), 'success');
const form = document.getElementById('form');
if (form) {
const fields = form.querySelectorAll('input, button');
fields.forEach((el) => {
el.disabled = true;
});
}
}
async function disableSetupPage() {
if (!isRegistered) return;
if (!confirm(t('hideConfirm'))) return;
const btn = document.getElementById('hideBtn');
if (btn) {
btn.disabled = true;
btn.textContent = t('hideWorking');
}
try {
const res = await fetch('/setup/disable', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
showMessage(t('hideDone'), 'success');
setTimeout(() => window.location.reload(), 600);
return;
}
showMessage(data.error || t('hideFailed'), 'error');
} catch (e) {
showMessage(t('hideFailed'), 'error');
}
if (btn) {
btn.disabled = false;
btn.textContent = t('hideBtn');
}
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
}
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
// password can be string or Uint8Array
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
// Handle password as string or Uint8Array
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
// Handle salt as string or Uint8Array
const saltBytes = (salt instanceof Uint8Array)
? salt
: encoder.encode(salt);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveBits']
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
keyLen * 8
);
return new Uint8Array(derivedBits);
}
// HKDF expand
async function hkdfExpand(prk, info, length) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
prk,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const infoBytes = encoder.encode(info);
const result = new Uint8Array(length);
let prev = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(prev.length + infoBytes.length + 1);
input.set(prev);
input.set(infoBytes, prev.length);
input[input.length - 1] = counter;
const signature = await crypto.subtle.sign('HMAC', key, input);
prev = new Uint8Array(signature);
const toCopy = Math.min(prev.length, length - offset);
result.set(prev.slice(0, toCopy), offset);
offset += toCopy;
counter++;
}
return result;
}
// Generate symmetric key
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
// Encrypt with AES-256-CBC
async function encryptAesCbc(data, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return new Uint8Array(encrypted);
}
// HMAC-SHA256
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
// Base64 encode
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
// Create encrypted string in Bitwarden format
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
// Calculate MAC over IV + encrypted data
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
// Generate RSA key pair
async function generateRsaKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1'
},
true,
['encrypt', 'decrypt']
);
// Export public key
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
// Export private key
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
return {
publicKey: publicKeyB64,
privateKey: privateKeyBytes
};
}
async function handleSubmit(event) {
event.preventDefault();
if (isRegistered) {
showMessage(t('doneTitle'), 'success');
return;
}
const name = document.getElementById('name').value;
const email = document.getElementById('email').value.toLowerCase();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage(t('errPwNotMatch'), 'error');
return;
}
if (password.length < 12) {
showMessage(t('errPwTooShort'), 'error');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = t('creating');
try {
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
// Generate master password hash (for authentication)
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
// Stretch master key using HKDF
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
// Generate symmetric key (will be encrypted with stretched master key)
const symmetricKey = generateSymmetricKey();
// Encrypt symmetric key with stretched master key
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
// Generate RSA key pair
const rsaKeys = await generateRsaKeyPair();
// Encrypt private key with symmetric key
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
// Register with server
const response = await fetch('/api/accounts/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
name: name,
masterPasswordHash: masterPasswordHashB64,
key: encryptedKey,
kdf: 0,
kdfIterations: iterations,
keys: {
publicKey: rsaKeys.publicKey,
encryptedPrivateKey: encryptedPrivateKey
}
})
});
const result = await response.json();
if (response.ok && result.success) {
showRegisteredView();
} else {
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
} catch (error) {
console.error('Registration error:', error);
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
}
// Check status on page load
applyI18n();
checkStatus();
</script>
</body>
</html>`;
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(setupPageHTML);
}
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
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.VAULT);
const registered = await storage.isRegistered();
if (!registered) {
return errorResponse('Registration required', 403);
}
await storage.setSetupDisabled();
return jsonResponse({ success: true });
}
+102 -84
View File
@@ -1,34 +1,71 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { errorResponse } from '../utils/response';
import { cipherToResponse, isCipherResponseSyncCompatible } 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';
// Format attachments for API response
function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
return attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0, // Android expects Int, not String
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
// 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, excludeDomains: boolean, excludeSends: boolean): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
}
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
const hit = await caches.default.match(cacheRequest);
if (!hit) return null;
return new Response(hit.body, hit);
}
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
await caches.default.put(cacheRequest, response.clone());
}
// GET /api/sync
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
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 user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const revisionDate = await storage.getRevisionDate(userId);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
}
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
// Build profile response
const profile: ProfileResponse = {
id: user.id,
name: user.name,
@@ -37,12 +74,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: [],
@@ -50,82 +87,63 @@ 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 = await storage.getAttachmentsByCipher(cipher.id);
cipherResponses.push({
id: cipher.id,
organizationId: null,
folderId: cipher.folderId,
type: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null,
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
}
const folderResponses: FolderResponse[] = [];
for (const folder of folders) {
folderResponses.push({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
});
};
// Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
object: 'folder',
}));
}
const sendResponses = sends.map(sendToResponse);
const syncResponse: SyncResponse = {
profile: profile,
profile,
folders: folderResponses,
collections: [],
ciphers: cipherResponses,
domains: {
equivalentDomains: [],
globalEquivalentDomains: [],
object: 'domains',
},
domains: excludeDomains
? null
: buildDomainsResponse(
domainSettings?.equivalentDomains || [],
domainSettings?.customEquivalentDomains || [],
domainSettings?.excludedGlobalEquivalentDomains || [],
{ omitExcludedGlobals: true }
),
policies: [],
sends: [],
userDecryption: {
masterPasswordUnlock: {
salt: user.email,
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory || null,
parallelism: user.kdfParallelism || null,
},
masterKeyEncryptedUserKey: user.key,
},
sends: sendResponses,
UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
UserDecryptionOptions: userDecryptionOptions,
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
object: 'sync',
};
return jsonResponse(syncResponse);
const response = new Response(JSON.stringify(syncResponse), {
status: 200,
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;
}
+120 -9
View File
@@ -1,18 +1,129 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue } from './handlers/backup';
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> {
// Security check: JWT_SECRET must be set
if (!env.JWT_SECRET) {
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
void ctx;
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
}
// Security check: warn if JWT_SECRET is too weak
if (env.JWT_SECRET.length < 32) {
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
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: 'Database initialization failed. Check server logs for details.',
ErrorModel: {
Message: 'Service temporarily unavailable',
Object: 'error',
},
},
500
);
return applyCors(normalizedRequest, resp);
}
return handleRequest(request, env);
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 };
+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;
}
+314
View File
@@ -0,0 +1,314 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
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';
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/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/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;
}
+474
View File
@@ -0,0 +1,474 @@
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 {
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/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;
}
+102 -376
View File
@@ -1,417 +1,143 @@
import { Env } 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';
// Icons handler - proxy to Bitwarden's official icon service
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
try {
// Use Bitwarden's official icon service
const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`;
const resp = await fetch(iconUrl, {
headers: { 'User-Agent': 'NodeWarden/1.0' },
redirect: 'follow',
});
if (resp.ok) {
const body = await resp.arrayBuffer();
return new Response(body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': 'public, max-age=604800', // 7 days
'Access-Control-Allow-Origin': '*',
},
});
}
return new Response(null, { status: 204 });
} catch {
return new Response(null, { status: 204 });
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;
}
return false;
}
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);
// Handle CORS preflight
if (method === 'OPTIONS') {
return handleCors();
}
// Route matching
try {
// Setup page (root)
if (path === '/' && method === 'GET') {
return handleSetupPage(request, env);
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' },
}
);
}
// Setup status
if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env);
}
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null;
// Disable setup page (one-way)
if (path === '/setup/disable' && method === 'POST') {
return handleDisableSetup(request, env);
}
// Favicon - return empty
if (path === '/favicon.ico') {
return new Response(null, { status: 204 });
}
// 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: '2025.12.0',
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
sso: '',
},
featureStates: {
'duo-redirect': true,
},
object: 'config',
});
}
// Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') {
return jsonResponse('2025.12.0');
}
// Registration endpoint (no auth required, but only works once)
if (path === '/api/accounts/register' && method === 'POST') {
return handleRegister(request, env);
}
// 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) {
return errorResponse('Unauthorized', 401);
}
const userId = payload.sub;
// API rate limiting for authenticated requests
const rateLimit = new RateLimitService(env.VAULT);
const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
return new Response(
JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
});
}
}
);
}
// Increment rate limit counter
await rateLimit.incrementApiCount(userId + ':' + clientId);
if (method === 'OPTIONS') {
return handleCors(request);
}
// 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('This operation is disabled', 403);
try {
const isLargeUploadPath =
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
}
// Account endpoints
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId);
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
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 });
}
// Revision date endpoint
if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId);
const userId = payload.sub;
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
}
// 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);
if (!isImportBypassRequest(request, path, method)) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
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': String(rateLimitCheck.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
}
);
}
}
// 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] || '';
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
if (authenticatedResponse) return authenticatedResponse;
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);
+209
View File
@@ -0,0 +1,209 @@
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',
'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);
}
+231 -23
View File
@@ -2,42 +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.VAULT);
this.storage = new StorageService(env.DB);
}
// Verify password hash (compare with stored hash)
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
// In Bitwarden, the client sends the password hash directly
// We compare the hashes
return inputHash === storedHash;
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 < 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(' ');
@@ -48,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;
}
}
+479
View File
@@ -0,0 +1,479 @@
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[];
};
}
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 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`);
}
}
}
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] = 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'),
]);
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,
},
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,
}, 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;
}
+933
View File
@@ -0,0 +1,933 @@
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'
| 'folders'
| 'ciphers'
| 'attachments';
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'domain_settings',
'user_revisions',
'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;
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 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 || []),
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('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,
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,
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,
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,
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,
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,
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',
};
}
+312 -126
View File
@@ -1,171 +1,357 @@
import { Env } from '../types';
import { LIMITS } from '../config/limits';
// Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// Rate limit configuration
const CONFIG = {
// Login attempt limits
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
// API rate limits (per minute)
API_REQUESTS_PER_MINUTE: 300, // General API rate limit
API_WINDOW_SECONDS: 60, // Rate limit window
};
// KV key prefixes
const KEYS = {
LOGIN_ATTEMPTS: 'ratelimit:login:',
API_RATE: 'ratelimit:api:',
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
};
export class RateLimitService {
constructor(private kv: KVNamespace) {}
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
/**
* Check and record login attempt
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
*/
async checkLoginAttempt(email: string): Promise<{
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
constructor(private db: D1Database) {}
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
await this.db
.prepare(
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
)
.bind(cutoff, nowMs)
.run();
RateLimitService.lastLoginIpCleanupAt = nowMs;
}
private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, ' +
'attempts INTEGER NOT NULL, ' +
'locked_until INTEGER, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
RateLimitService.loginIpTableReady = true;
}
async checkLoginAttempt(ip: string): Promise<{
allowed: boolean;
remainingAttempts: number;
retryAfterSeconds?: number;
}> {
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
const data = await this.kv.get(key);
if (!data) {
await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
.bind(key)
.first<{ attempts: number; locked_until: number | null }>();
if (!row) {
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
}
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
const now = Date.now();
// Check if currently locked out
if (record.lockedUntil && record.lockedUntil > now) {
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
if (row.locked_until && row.locked_until > now) {
return {
allowed: false,
remainingAttempts: 0,
retryAfterSeconds,
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
};
}
// If lockout expired, reset
if (record.lockedUntil && record.lockedUntil <= now) {
await this.kv.delete(key);
if (row.locked_until && row.locked_until <= now) {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
}
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
return { allowed: true, remainingAttempts };
}
/**
* Record a failed login attempt
*/
async recordFailedLogin(email: string): Promise<{
locked: boolean;
retryAfterSeconds?: number;
}> {
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
const data = await this.kv.get(key);
let record: { attempts: number; lockedUntil?: number };
if (data) {
record = JSON.parse(data);
record.attempts += 1;
} else {
record = { attempts: 1 };
}
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
await this.ensureLoginIpTable();
// Check if should lock out
if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.kv.put(key, JSON.stringify(record), {
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer
});
return {
locked: true,
retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
};
}
const key = ip.trim() || 'unknown';
const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
// Store with expiration (auto-reset after lockout period even without lockout)
await this.kv.put(key, JSON.stringify(record), {
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
});
// D1 in Workers forbids raw BEGIN/COMMIT statements.
// Use a single atomic UPSERT to increment attempts.
// This is concurrency-safe because the row is keyed by IP.
await this.db
.prepare(
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
)
.bind(key, now)
.run();
const row = await this.db
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
.bind(key)
.first<{ attempts: number }>();
const attempts = row?.attempts || 1;
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.db
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
.bind(lockedUntil, now, key)
.run();
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
}
return { locked: false };
}
/**
* Clear login attempts on successful login
*/
async clearLoginAttempts(email: string): Promise<void> {
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
await this.kv.delete(key);
async clearLoginAttempts(ip: string): Promise<void> {
await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
}
/**
* Check API rate limit for a user or IP
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
*/
async checkApiRateLimit(identifier: string): Promise<{
allowed: boolean;
remaining: number;
retryAfterSeconds?: number;
}> {
const now = Math.floor(Date.now() / 1000);
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
// 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,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds;
const ttl = Math.max(1, windowEnd - nowSec);
const countStr = await this.kv.get(key);
const count = countStr ? parseInt(countStr, 10) : 0;
const cache = await caches.open('rate-limit');
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
return {
allowed: false,
remaining: 0,
retryAfterSeconds,
};
const cached = await cache.match(cacheKey);
let count = 0;
if (cached) {
count = parseInt(await cached.text(), 10) || 0;
}
return {
allowed: true,
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
};
if (count >= maxRequests) {
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
}
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) };
}
/**
* Increment API request count
*/
async incrementApiCount(identifier: string): Promise<void> {
const now = Math.floor(Date.now() / 1000);
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
// 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);
}
const countStr = await this.kv.get(key);
const count = countStr ? parseInt(countStr, 10) : 0;
await this.kv.put(key, (count + 1).toString(), {
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
});
async consumeBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
}
}
/**
* Get client identifier from request (IP or CF-Connecting-IP)
*/
export function getClientIdentifier(request: Request): string {
// Cloudflare provides the real client IP
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;
// Fallback for local development
const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim();
// Last resort
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;
}
+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;
}
+158
View File
@@ -0,0 +1,158 @@
// 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 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;
}
+618 -180
View File
@@ -1,256 +1,694 @@
import { Env, User, Cipher, Folder, Attachment } from '../types';
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
import { LIMITS } from '../config/limits';
import { ensureStorageSchema } from './storage-schema';
import {
getConfigValue as getStoredConfigValue,
isRegistered as getRegisteredFlag,
setConfigValue as saveConfigValue,
setRegistered as saveRegisteredFlag,
} from './storage-config-repo';
import {
createFirstUser as createFirstStoredUser,
createUser as createStoredUser,
deleteUserById as deleteStoredUserById,
getAllUsers as listStoredUsers,
getUser as findStoredUserByEmail,
getUserById as findStoredUserById,
getUserCount as countStoredUsers,
saveUser as saveStoredUser,
} from './storage-user-repo';
import {
type AuditLogListOptions,
createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
listInvites as listStoredInvites,
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
bulkDeleteFolders as deleteStoredFolders,
clearFolderFromCiphers as clearStoredFolderFromCiphers,
deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders,
getFolder as findStoredFolder,
getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder,
} from './storage-folder-repo';
import {
bulkArchiveCiphers as archiveStoredCiphers,
bulkDeleteCiphers as deleteStoredCiphers,
bulkMoveCiphers as moveStoredCiphers,
bulkRestoreCiphers as restoreStoredCiphers,
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher,
getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher,
deleteCipher as deleteStoredCipher,
} from './storage-cipher-repo';
import {
addAttachmentToCipher as attachStoredAttachmentToCipher,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment,
getAttachment as findStoredAttachment,
getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId,
saveAttachment as saveStoredAttachment,
updateCipherRevisionDate as updateStoredCipherRevisionDate,
} from './storage-attachment-repo';
import {
bulkDeleteSends as deleteStoredSends,
deleteSend as deleteStoredSend,
getAllSends as listStoredSends,
getSend as findStoredSend,
getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount,
saveSend as saveStoredSend,
} from './storage-send-repo';
import {
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry,
deleteRefreshToken as deleteStoredRefreshToken,
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
getRefreshTokenRecord as findStoredRefreshTokenRecord,
saveRefreshToken as saveStoredRefreshToken,
} from './storage-refresh-token-repo';
import {
deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice,
getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
} from './storage-device-repo';
import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo';
import {
getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate,
} from './storage-revision-repo';
import {
getUserDomainSettings as getStoredUserDomainSettings,
saveUserDomainSettings as saveStoredUserDomainSettings,
} from './storage-domain-rules-repo';
const KEYS = {
CONFIG_REGISTERED: 'config:registered',
CONFIG_SETUP_DISABLED: 'config:setup_disabled',
USER_PREFIX: 'user:',
CIPHER_PREFIX: 'cipher:',
FOLDER_PREFIX: 'folder:',
ATTACHMENT_PREFIX: 'attachment:',
CIPHERS_INDEX: 'index:ciphers',
FOLDERS_INDEX: 'index:folders',
ATTACHMENTS_INDEX: 'index:attachments',
REFRESH_TOKEN_PREFIX: 'refresh:',
REVISION_DATE_PREFIX: 'revision:',
};
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// IMPORTANT:
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
// D1-backed storage.
// Contract:
// - All methods are scoped by userId where applicable.
// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
// - Revision date is maintained per user for Bitwarden sync.
export class StorageService {
constructor(private kv: KVNamespace) {}
private static attachmentTokenTableReady = false;
private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {}
/**
* D1 .bind() throws on `undefined` values. This helper converts every
* `undefined` in the argument list to `null` so we never hit that runtime
* error - especially important after the opaque-passthrough change where
* client-supplied JSON may omit fields we later reference as columns.
*/
private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement {
return stmt.bind(...values.map(v => v === undefined ? null : v));
}
private sqlChunkSize(fixedBindCount: number): number {
return Math.max(
1,
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
);
}
private async sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
}
private async refreshTokenKey(token: string): Promise<string> {
const digest = await this.sha256Hex(token);
return `sha256:${digest}`;
}
private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise<void> {
if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run();
StorageService.lastRefreshTokenCleanupAt = nowMs;
}
// --- Database initialization ---
// Strategy:
// - Run only once per isolate.
// - Execute idempotent schema SQL on first request in each isolate.
// - Keep statements idempotent so updates are safe.
async initializeDatabase(): Promise<void> {
if (StorageService.schemaVerified) return;
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
}
StorageService.schemaVerified = true;
}
// --- Config / setup ---
// Registration status
async isRegistered(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
return value === 'true';
return getRegisteredFlag(this.db);
}
async getConfigValue(key: string): Promise<string | null> {
return getStoredConfigValue(this.db, key);
}
async setConfigValue(key: string, value: string): Promise<void> {
await saveConfigValue(this.db, key, value);
}
async setRegistered(): Promise<void> {
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
await saveRegisteredFlag(this.db);
}
// Setup page visibility
async isSetupDisabled(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED);
return value === 'true';
}
// --- Users ---
async setSetupDisabled(): Promise<void> {
await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true');
}
// User operations
async getUser(email: string): Promise<User | null> {
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
return data ? JSON.parse(data) : null;
return findStoredUserByEmail(this.db, email);
}
async getUserById(id: string): Promise<User | null> {
// Get user email from id mapping
const email = await this.kv.get(`userid:${id}`);
if (!email) return null;
return this.getUser(email);
return findStoredUserById(this.db, id);
}
async getUserCount(): Promise<number> {
return countStoredUsers(this.db);
}
async getAllUsers(): Promise<User[]> {
return listStoredUsers(this.db);
}
async saveUser(user: User): Promise<void> {
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
await saveStoredUser(this.db, this.safeBind.bind(this), user);
}
// Cipher operations
async getCipher(id: string): Promise<Cipher | null> {
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
async createUser(user: User): Promise<void> {
await createStoredUser(this.db, this.safeBind.bind(this), user);
}
async saveCipher(cipher: Cipher): Promise<void> {
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
// Update index
const index = await this.getCipherIds(cipher.userId);
if (!index.includes(cipher.id)) {
index.push(cipher.id);
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
}
async createFirstUser(user: User): Promise<boolean> {
return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
}
async deleteCipher(id: string, userId: string): Promise<void> {
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
// Update index
const index = await this.getCipherIds(userId);
const newIndex = index.filter(cid => cid !== id);
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex));
async deleteUserById(id: string): Promise<boolean> {
return deleteStoredUserById(this.db, id);
}
async getCipherIds(userId: string): Promise<string[]> {
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
return data ? JSON.parse(data) : [];
async createInvite(invite: Invite): Promise<void> {
await createStoredInvite(this.db, invite);
}
async getAllCiphers(userId: string): Promise<Cipher[]> {
const ids = await this.getCipherIds(userId);
const ciphers: Cipher[] = [];
for (const id of ids) {
const cipher = await this.getCipher(id);
if (cipher) ciphers.push(cipher);
}
return ciphers;
async getInvite(code: string): Promise<Invite | null> {
return findStoredInvite(this.db, code);
}
// Folder operations
async getFolder(id: string): Promise<Folder | null> {
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
async listInvites(includeInactive: boolean = false): Promise<Invite[]> {
return listStoredInvites(this.db, includeInactive);
}
async saveFolder(folder: Folder): Promise<void> {
await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder));
// Update index
const index = await this.getFolderIds(folder.userId);
if (!index.includes(folder.id)) {
index.push(folder.id);
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index));
}
async markInviteUsed(code: string, userId: string): Promise<boolean> {
return markStoredInviteUsed(this.db, code, userId);
}
async deleteFolder(id: string, userId: string): Promise<void> {
await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`);
// Update index
const index = await this.getFolderIds(userId);
const newIndex = index.filter(fid => fid !== id);
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex));
async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code);
}
async getFolderIds(userId: string): Promise<string[]> {
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
return data ? JSON.parse(data) : [];
async deleteAllInvites(): Promise<number> {
return deleteStoredInvites(this.db);
}
async getAllFolders(userId: string): Promise<Folder[]> {
const ids = await this.getFolderIds(userId);
const folders: Folder[] = [];
for (const id of ids) {
const folder = await this.getFolder(id);
if (folder) folders.push(folder);
}
return folders;
async createAuditLog(log: AuditLog): Promise<void> {
await createStoredAuditLog(this.db, log);
}
// Refresh token operations
async saveRefreshToken(token: string, userId: string): Promise<void> {
// Store refresh token with 30 day expiry
await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, {
expirationTtl: 30 * 24 * 60 * 60,
});
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
return listStoredAuditLogs(this.db, options);
}
async getRefreshTokenUserId(token: string): Promise<string | null> {
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
async pruneAuditLogs(beforeIso: string): Promise<number> {
return pruneStoredAuditLogs(this.db, beforeIso);
}
async deleteRefreshToken(token: string): Promise<void> {
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
return pruneStoredAuditLogsToMax(this.db, maxEntries);
}
// Revision date operations (for incremental sync)
async getRevisionDate(userId: string): Promise<string> {
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
return date || new Date().toISOString();
async clearAuditLogs(): Promise<number> {
return clearStoredAuditLogs(this.db);
}
async updateRevisionDate(userId: string): Promise<string> {
const date = new Date().toISOString();
await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date);
return date;
// --- Domain rules ---
async getUserDomainSettings(userId: string) {
return getStoredUserDomainSettings(this.db, userId);
}
// Bulk cipher operations
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
const ciphers: Cipher[] = [];
for (const id of ids) {
const cipher = await this.getCipher(id);
if (cipher && cipher.userId === userId) {
ciphers.push(cipher);
}
}
return ciphers;
}
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
const now = new Date().toISOString();
for (const id of ids) {
const cipher = await this.getCipher(id);
if (cipher && cipher.userId === userId) {
cipher.folderId = folderId;
cipher.updatedAt = now;
await this.saveCipher(cipher);
}
}
async saveUserDomainSettings(
userId: string,
equivalentDomains: string[][],
customEquivalentDomains: CustomEquivalentDomain[],
excludedGlobalEquivalentDomains: number[]
): Promise<void> {
await saveStoredUserDomainSettings(
this.db,
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains,
new Date().toISOString()
);
await this.updateRevisionDate(userId);
}
// Attachment operations
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {
return findStoredCipher(this.db, id);
}
async saveCipher(cipher: Cipher): Promise<void> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
}
async deleteCipher(id: string, userId: string): Promise<void> {
await deleteStoredCipher(this.db, id, userId);
}
async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
return softDeleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkRestoreCiphers(ids: string[], userId: string): Promise<string | null> {
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async getAllCiphers(userId: string): Promise<Cipher[]> {
return listStoredCiphers(this.db, userId);
}
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
return listStoredCiphersPage(this.db, userId, includeDeleted, limit, offset);
}
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
return listStoredCiphersByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
}
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> {
return moveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, folderId, userId);
}
// --- Folders ---
async getFolder(id: string): Promise<Folder | null> {
return findStoredFolder(this.db, id);
}
async saveFolder(folder: Folder): Promise<void> {
await saveStoredFolder(this.db, folder);
}
async deleteFolder(id: string, userId: string): Promise<void> {
await deleteStoredFolder(this.db, id, userId);
}
async bulkDeleteFolders(ids: string[], userId: string): Promise<string | null> {
return deleteStoredFolders(
this.db,
userId,
ids,
this.sqlChunkSize.bind(this),
this.updateRevisionDate.bind(this)
);
}
// Clear folder references from all ciphers owned by the user.
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
await clearStoredFolderFromCiphers(this.db, userId, folderId);
}
async getAllFolders(userId: string): Promise<Folder[]> {
return listStoredFolders(this.db, userId);
}
async getFoldersPage(userId: string, limit: number, offset: number): Promise<Folder[]> {
return listStoredFoldersPage(this.db, userId, limit, offset);
}
// --- Attachments ---
async getAttachment(id: string): Promise<Attachment | null> {
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
return findStoredAttachment(this.db, id);
}
async saveAttachment(attachment: Attachment): Promise<void> {
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
}
async deleteAttachment(id: string): Promise<void> {
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
await deleteStoredAttachment(this.db, id);
}
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
return data ? JSON.parse(data) : [];
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
}
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
const ids = await this.getAttachmentIdsByCipher(cipherId);
const attachments: Attachment[] = [];
for (const id of ids) {
const attachment = await this.getAttachment(id);
if (attachment) attachments.push(attachment);
}
return attachments;
return listStoredAttachmentsByCipher(this.db, cipherId);
}
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
return listStoredAttachmentsByCipherIds(this.db, this.sqlChunkSize.bind(this), cipherIds);
}
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
return listStoredAttachmentsByUserId(this.db, userId);
}
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId);
if (!ids.includes(attachmentId)) {
ids.push(attachmentId);
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
}
}
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId);
const newIds = ids.filter(id => id !== attachmentId);
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
const ids = await this.getAttachmentIdsByCipher(cipherId);
for (const id of ids) {
await this.deleteAttachment(id);
}
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
await deleteStoredAttachmentsByCipher(this.db, cipherId);
}
async updateCipherRevisionDate(cipherId: string): Promise<void> {
const cipher = await this.getCipher(cipherId);
if (cipher) {
cipher.updatedAt = new Date().toISOString();
await this.saveCipher(cipher);
await this.updateRevisionDate(cipher.userId);
async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> {
return updateStoredCipherRevisionDate(
this.getCipher.bind(this),
this.saveCipher.bind(this),
this.updateRevisionDate.bind(this),
cipherId
);
}
// --- Refresh tokens ---
async saveRefreshToken(
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
await saveStoredRefreshToken(
this.db,
this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this),
token,
userId,
expiresAt,
deviceIdentifier,
deviceSessionStamp
);
}
async getRefreshTokenRecord(token: string): Promise<RefreshTokenRecord | null> {
return findStoredRefreshTokenRecord(
this.db,
this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this),
this.deleteRefreshToken.bind(this),
token
);
}
async getRefreshTokenUserId(token: string): Promise<string | null> {
const record = await this.getRefreshTokenRecord(token);
return record?.userId ?? null;
}
async deleteRefreshToken(token: string): Promise<void> {
await deleteStoredRefreshToken(this.db, this.refreshTokenKey.bind(this), token);
}
// --- Sends ---
async getSend(id: string): Promise<Send | null> {
return findStoredSend(this.db, id);
}
async saveSend(send: Send): Promise<void> {
await saveStoredSend(this.db, this.safeBind.bind(this), send);
}
/**
* Atomically increment access_count and update updated_at.
* Returns true if the row was updated (send still available),
* false if max_access_count has already been reached.
*/
async incrementSendAccessCount(sendId: string): Promise<boolean> {
return incrementStoredSendAccessCount(this.db, sendId);
}
async deleteSend(id: string, userId: string): Promise<void> {
await deleteStoredSend(this.db, id, userId);
}
async getSendsByIds(ids: string[], userId: string): Promise<Send[]> {
return listStoredSendsByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
}
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> {
return deleteStoredSends(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async getAllSends(userId: string): Promise<Send[]> {
return listStoredSends(this.db, userId);
}
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
return listStoredSendsPage(this.db, userId, limit, offset);
}
async deleteRefreshTokensByUserId(userId: string): Promise<number> {
return deleteStoredRefreshTokensByUserId(this.db, userId);
}
async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
}
// Keep a short overlap window for rotated refresh token to reduce
// multi-context refresh races (e.g. browser extension popup/background).
// Expiry is only tightened, never extended.
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs);
}
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
const digest = await this.sha256Hex(token);
return `sha256:${digest}`;
}
// --- Devices ---
async upsertDevice(
userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
}
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
return getKnownStoredDevice(this.db, userId, deviceIdentifier);
}
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
return getKnownStoredDeviceByEmail(this.getUser.bind(this), this.isKnownDevice.bind(this), email, deviceIdentifier);
}
async getDevicesByUserId(userId: string): Promise<Device[]> {
return listStoredDevicesByUserId(this.db, userId);
}
async getDevice(userId: string, deviceIdentifier: string): Promise<Device | null> {
return findStoredDevice(this.db, userId, deviceIdentifier);
}
async updateDeviceKeys(
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
}
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
return deleteStoredDevice(this.db, userId, deviceIdentifier);
}
async deleteDevicesByUserId(userId: string): Promise<number> {
return deleteStoredDevicesByUserId(this.db, userId);
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
return listStoredTrustedTokenSummaries(this.db, userId);
}
async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
return deleteStoredTrustedTokensByDevice(this.db, userId, deviceIdentifier);
}
async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise<number> {
return deleteStoredTrustedTokensByUserId(this.db, userId);
}
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
}
// --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken(
token: string,
userId: string,
deviceIdentifier: string,
expiresAtMs?: number
): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
await saveStoredTrustedDeviceToken(this.db, this.trustedTwoFactorTokenKey.bind(this), token, userId, deviceIdentifier, expiresAt);
}
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
}
// --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> {
return getStoredRevisionDate(this.db, userId);
}
async updateRevisionDate(userId: string): Promise<string> {
return updateStoredRevisionDate(this.db, userId);
}
// --- One-time attachment download tokens ---
private async ensureUsedAttachmentDownloadTokenTable(): Promise<void> {
if (StorageService.attachmentTokenTableReady) return;
await ensureStoredAttachmentTokenTable(this.db);
StorageService.attachmentTokenTableReady = true;
}
// Marks an attachment download token JTI as consumed.
// Returns true only on first use. Reuse returns false.
async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise<boolean> {
await this.ensureUsedAttachmentDownloadTokenTable();
const result = await consumeStoredAttachmentDownloadToken(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastAttachmentTokenCleanupAt,
StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS,
jti,
expUnixSeconds
);
if (result.cleanedUpAt !== null) {
StorageService.lastAttachmentTokenCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
}
+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"}
]
+257 -11
View File
@@ -1,10 +1,24 @@
// Environment bindings
export interface Env {
VAULT: KVNamespace;
ATTACHMENTS: R2Bucket;
DB: D1Database;
NOTIFICATIONS_HUB: 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;
}
export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model
export interface Attachment {
id: string;
@@ -19,7 +33,8 @@ export interface Attachment {
export interface User {
id: string;
email: string;
name: string;
name: string | null;
masterPasswordHint: string | null;
masterPasswordHash: string;
key: string;
privateKey: string | null;
@@ -29,10 +44,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,
@@ -115,7 +188,7 @@ export interface Cipher {
userId: string;
type: CipherType;
folderId: string | null;
name: string;
name: string | null;
notes: string | null;
favorite: boolean;
login: CipherLogin | null;
@@ -129,7 +202,10 @@ 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;
}
// Folder model
@@ -141,14 +217,138 @@ 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 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
email: string;
name: string;
name: string | null;
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;
@@ -166,13 +366,18 @@ export interface MasterPasswordUnlockKdf {
export interface MasterPasswordUnlock {
Kdf: MasterPasswordUnlockKdf;
MasterKeyEncryptedUserKey: string;
MasterKeyWrappedUserKey: string;
Salt: string;
Object: string;
}
export interface UserDecryptionOptions {
HasMasterPassword: boolean;
Object: string;
MasterPasswordUnlock?: MasterPasswordUnlock;
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
MasterPasswordUnlock: MasterPasswordUnlock;
TrustedDeviceOption: null;
KeyConnectorOption: null;
}
// API Response types
@@ -180,7 +385,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;
@@ -191,12 +398,23 @@ 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 {
id: string;
name: string;
name: string | null;
email: string;
emailVerified: boolean;
premium: boolean;
@@ -215,6 +433,9 @@ export interface ProfileResponse {
forcePasswordReset: boolean;
avatarColor: string | null;
creationDate: string;
verifyDevices?: boolean;
role?: UserRole;
status?: UserStatus;
object: string;
}
@@ -223,7 +444,7 @@ export interface CipherResponse {
organizationId: string | null;
folderId: string | null;
type: number;
name: string;
name: string | null;
notes: string | null;
favorite: boolean;
login: CipherLogin | null;
@@ -247,6 +468,8 @@ export interface CipherResponse {
attachments: any[] | null;
key: string | null;
encryptedFor: string | null;
/** Allow unknown fields to pass through to clients transparently. */
[key: string]: any;
}
export interface CipherPermissions {
@@ -258,6 +481,7 @@ export interface FolderResponse {
id: string;
name: string;
revisionDate: string;
creationDate: string;
object: string;
}
@@ -268,7 +492,29 @@ export interface SyncResponse {
ciphers: CipherResponse[];
domains: any;
policies: any[];
sends: any[];
userDecryption: any | null;
sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: number;
iterations: number;
memory: number | null;
parallelism: number | null;
};
masterKeyWrappedUserKey: string;
masterKeyEncryptedUserKey: string;
salt: string;
} | null;
} | null;
object: string;
}
+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,
};
}
+277 -31
View File
@@ -1,4 +1,7 @@
import { JWTPayload } from '../types';
import { LIMITS } from '../config/limits';
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
// Base64 URL encode
function base64UrlEncode(data: Uint8Array): string {
@@ -18,8 +21,25 @@ 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 = 7200): Promise<string> {
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' };
const now = Math.floor(Date.now() / 1000);
@@ -39,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));
@@ -62,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);
@@ -90,13 +98,21 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
// Create refresh token (simple random string)
export function createRefreshToken(): string {
const bytes = new Uint8Array(32);
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
// File download token payload
export interface FileDownloadClaims {
cipherId: string;
attachmentId: string;
jti: string;
exp: number;
}
export interface AttachmentUploadClaims {
userId: string;
cipherId: string;
attachmentId: string;
exp: number;
@@ -114,7 +130,8 @@ export async function createFileDownloadToken(
const payload: FileDownloadClaims = {
cipherId,
attachmentId,
exp: now + 300, // 5 minutes
jti: createRefreshToken(),
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
};
const encoder = new TextEncoder();
@@ -123,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));
@@ -149,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);
@@ -174,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;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { LIMITS } from '../config/limits';
const MAX_PAGE_SIZE = LIMITS.pagination.maxPageSize;
export interface PaginationRequest {
limit: number;
offset: number;
}
export function parsePagination(url: URL): PaginationRequest | null {
const pageSizeRaw = url.searchParams.get('pageSize');
const continuationToken = url.searchParams.get('continuationToken');
if (!pageSizeRaw && !continuationToken) return null;
const pageSize = pageSizeRaw ? Number(pageSizeRaw) : LIMITS.pagination.defaultPageSize;
if (!Number.isInteger(pageSize) || pageSize <= 0) return null;
const limit = Math.min(pageSize, MAX_PAGE_SIZE);
const offset = decodeContinuationToken(continuationToken);
return { limit, offset };
}
export function encodeContinuationToken(offset: number): string {
return btoa(String(offset));
}
export function decodeContinuationToken(token: string | null): number {
if (!token) return 0;
try {
const decoded = atob(token);
const offset = Number(decoded);
if (!Number.isInteger(offset) || offset < 0) return 0;
return offset;
} catch {
return 0;
}
}
+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;
}
+112 -14
View File
@@ -1,10 +1,119 @@
import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
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 isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
|| path === '/config'
|| path === '/api/config'
|| path === '/api/version'
);
}
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': allowHeaders.join(', '),
'Access-Control-Expose-Headers': '*',
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
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;
}
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,
headers,
});
}
// JSON response helper
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json',
...getCorsHeaders(),
...headers,
},
});
@@ -40,21 +149,11 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
);
}
// CORS headers
export function getCorsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version',
'Access-Control-Max-Age': '86400',
};
}
// Handle CORS preflight
export function handleCors(): Response {
export function handleCors(request: Request): Response {
return new Response(null, {
status: 204,
headers: getCorsHeaders(),
headers: buildCorsHeaders(request),
});
}
@@ -64,7 +163,6 @@ export function htmlResponse(html: string, status: number = 200): Response {
status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
...getCorsHeaders(),
},
});
}
+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);
}
+70
View File
@@ -0,0 +1,70 @@
import { User, UserDecryptionOptions } 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'>
): UserDecryptionOptions {
return {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
};
}
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"]
}
+94
View File
@@ -0,0 +1,94 @@
<!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" />
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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