diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index be71155295..21346a2129 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -54,6 +54,11 @@ const messageSchema = mongoose.Schema( type: String, meiliIndex: true, }, + // If the message is encrypted (and stored in 'text'), + // then this field should hold the IV used during encryption. + messageEncryptionIV: { + type: String, + }, summary: { type: String, }, diff --git a/api/models/schema/shareSchema.js b/api/models/schema/shareSchema.js index 12699a39ec..4faf114033 100644 --- a/api/models/schema/shareSchema.js +++ b/api/models/schema/shareSchema.js @@ -23,6 +23,13 @@ const shareSchema = mongoose.Schema( type: Boolean, default: true, }, + // --- Field for re-encrypting the conversation key for the forked user --- + encryptionKeys: [ + { + user: { type: String, index: true }, + encryptedKey: { type: String }, + }, + ], }, { timestamps: true }, ); diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index f586553367..db671bb927 100644 --- a/api/models/schema/userSchema.js +++ b/api/models/schema/userSchema.js @@ -27,6 +27,10 @@ const { SystemRoles } = require('librechat-data-provider'); * @property {Array} [plugins=[]] - List of plugins used by the user * @property {Array.} [refreshToken] - List of sessions with refresh tokens * @property {Date} [expiresAt] - Optional expiration date of the file + * @property {string} [encryptionPublicKey] - The user's public key for E2EE (client-generated) + * @property {string} [encryptedPrivateKey] - The user's private key encrypted with a user-defined passphrase + * @property {string} [encryptionSalt] - The salt used for PBKDF2 during encryption + * @property {string} [encryptionIV] - The initialization vector used for encryption (AES-GCM) * @property {Date} [createdAt] - Date when the user was created (added by timestamps) * @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps) */ @@ -132,6 +136,27 @@ const userSchema = mongoose.Schema( type: Boolean, default: false, }, + // --- New Fields for E2EE --- + encryptionPublicKey: { + type: String, + required: false, + // Provided by the client after key generation. + }, + encryptedPrivateKey: { + type: String, + required: false, + // The private key encrypted on the client with the user’s encryption passphrase. + }, + encryptionSalt: { + type: String, + required: false, + // Salt used for PBKDF2 when encrypting the private key. + }, + encryptionIV: { + type: String, + required: false, + // IV used for AES-GCM encryption of the private key. + }, }, { timestamps: true }, diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 17089e8fdc..084d35cd6e 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -7,6 +7,7 @@ const { deleteMessages, deleteUserById, deleteAllUserSessions, + updateUser, } = require('~/models'); const User = require('~/models/User'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); @@ -162,6 +163,34 @@ const resendVerificationController = async (req, res) => { } }; +const updateUserEncryptionController = async (req, res) => { + try { + const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body; + + // Validate required parameters + if (!encryptionPublicKey || !encryptedPrivateKey || !encryptionSalt || !encryptionIV) { + return res.status(400).json({ message: 'Missing encryption parameters.' }); + } + + // Use the helper function to update the user. + const updatedUser = await updateUser(req.user.id, { + encryptionPublicKey, + encryptedPrivateKey, + encryptionSalt, + encryptionIV, + }); + + if (!updatedUser) { + return res.status(404).json({ message: 'User not found.' }); + } + + res.status(200).json({ success: true }); + } catch (error) { + logger.error('[updateUserEncryptionController]', error); + res.status(500).json({ message: 'Something went wrong updating encryption keys.' }); + } +}; + module.exports = { getUserController, getTermsStatusController, @@ -170,4 +199,5 @@ module.exports = { verifyEmailController, updateUserPluginsController, resendVerificationController, + updateUserEncryptionController, }; diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 34d28fd937..a13836f3ad 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -8,12 +8,14 @@ const { resendVerificationController, getTermsStatusController, acceptTermsController, + updateUserEncryptionController, } = require('~/server/controllers/UserController'); const router = express.Router(); router.get('/', requireJwtAuth, getUserController); router.get('/terms', requireJwtAuth, getTermsStatusController); +router.put('/encryption', requireJwtAuth, updateUserEncryptionController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController); router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController); diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx index 1fd2e5c7bd..7bd100938f 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import EncryptionPassphrase from './EncryptionPassphrase'; import MaximizeChatSpace from './MaximizeChatSpace'; import FontSizeSelector from './FontSizeSelector'; import SendMessageKeyEnter from './EnterToSend'; @@ -35,6 +36,9 @@ function Chat() {
+
+ +
diff --git a/client/src/components/Nav/SettingsTabs/Chat/EncryptionPassphrase.tsx b/client/src/components/Nav/SettingsTabs/Chat/EncryptionPassphrase.tsx new file mode 100644 index 0000000000..85ad8cbd05 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Chat/EncryptionPassphrase.tsx @@ -0,0 +1,249 @@ +import React, { useState, ChangeEvent, FC } from 'react'; +import { + Button, + OGDialog, + OGDialogContent, + OGDialogHeader, + OGDialogTitle, + Input, +} from '~/components'; +import { Lock, Key } from 'lucide-react'; +import { useAuthContext, useLocalize } from '~/hooks'; +import { useSetRecoilState } from 'recoil'; +import store from '~/store'; +import type { TUser } from 'librechat-data-provider'; +import { useToastContext } from '~/Providers'; +import { useSetUserEncryptionMutation } from '~/data-provider'; + +// Helper: Convert a Base64 string to Uint8Array. +const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = window.atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +// Helper: Convert a Uint8Array to a hex string (for debugging). +const uint8ArrayToHex = (array: Uint8Array): string => + Array.from(array) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + +// Derive an AES-GCM key from the passphrase using PBKDF2. +const deriveKey = async (passphrase: string, salt: Uint8Array): Promise => { + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'] + ); + const derivedKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, // Adjust as needed for security/performance trade-off + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + // Debug: export the derived key and log it. + const rawKey = await window.crypto.subtle.exportKey('raw', derivedKey); + console.debug('Derived key (hex):', uint8ArrayToHex(new Uint8Array(rawKey))); + return derivedKey; +}; + +// Decrypt the encrypted private key using the derived key and IV. +const decryptPrivateKey = async ( + derivedKey: CryptoKey, + iv: Uint8Array, + ciphertextBase64: string +): Promise => { + const ciphertext = base64ToUint8Array(ciphertextBase64); + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + derivedKey, + ciphertext + ); + const decoder = new TextDecoder(); + return decoder.decode(decryptedBuffer); +}; + +const UserKeysSettings: FC = () => { + const localize = useLocalize(); + const { user } = useAuthContext(); + const setUser = useSetRecoilState(store.user); + const { showToast } = useToastContext(); + const [dialogOpen, setDialogOpen] = useState(false); + const [passphrase, setPassphrase] = useState(''); + + // Mutation hook for updating user encryption keys. + const { mutateAsync: setEncryption } = useSetUserEncryptionMutation({ + onError: (error) => { + console.error('Error updating encryption keys:', error); + showToast({ message: localize('com_ui_upload_error'), status: 'error' }); + }, + }); + + // Activation/Update flow: Generate new keys and update user encryption fields. + const activateEncryption = async (): Promise<{ + encryptionPublicKey: string; + encryptedPrivateKey: string; + encryptionSalt: string; + encryptionIV: string; + } | void> => { + if (!passphrase) { + console.error('Passphrase is empty.'); + return; + } + if (!user) { + console.error('User object is missing.'); + return; + } + + try { + console.debug('[Debug] Activating E2EE encryption...'); + + // Generate a new RSA-OAEP key pair. + const keyPair = await window.crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'] + ); + // Export public and private keys. + const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey); + const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer))); + const privateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer))); + console.debug('New public key:', publicKeyBase64); + console.debug('New private key (plaintext):', privateKeyBase64); + + // Generate a salt and IV. + const salt = window.crypto.getRandomValues(new Uint8Array(16)); // 16 bytes salt + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12 bytes IV + + // Derive a symmetric key from the passphrase. + const derivedKey = await deriveKey(passphrase, salt); + + // Encrypt the private key. + const encoder = new TextEncoder(); + const privateKeyBytes = encoder.encode(privateKeyBase64); + const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + derivedKey, + privateKeyBytes + ); + const encryptedPrivateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer))); + + // Convert salt and IV to Base64. + const saltBase64 = btoa(String.fromCharCode(...salt)); + const ivBase64 = btoa(String.fromCharCode(...iv)); + + console.debug('Activation complete:'); + console.debug('Encrypted private key:', encryptedPrivateKeyBase64); + console.debug('Salt (base64):', saltBase64); + console.debug('IV (base64):', ivBase64); + + return { + encryptionPublicKey: publicKeyBase64, + encryptedPrivateKey: encryptedPrivateKeyBase64, + encryptionSalt: saltBase64, + encryptionIV: ivBase64, + }; + } catch (error) { + console.error('Error during activation:', error); + } + }; + + const handleSubmit = async (): Promise => { + // Activate encryption (or re-activate) by generating new keys. + const newEncryption = await activateEncryption(); + if (newEncryption) { + try { + // Call the mutation to update the backend. + await setEncryption(newEncryption); + showToast({ message: localize('com_ui_upload_success') }); + // Update local user state with the new encryption keys. + setUser((prev) => ({ + ...prev, + ...newEncryption, + }) as TUser); + } catch (error) { + console.error('Mutation error:', error); + } + } + setDialogOpen(false); + setPassphrase(''); + }; + + const handleInputChange = (e: ChangeEvent): void => { + setPassphrase(e.target.value); + }; + + return ( + <> + {/* List item style */} +
+
+ + + {localize('com_nav_chat_encryption_settings')} + +
+ +
+ + {/* Optionally display current public key */} + {user?.encryptionPublicKey && ( +
+ {localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}... +
+ )} + + {/* Dialog for setting/updating keys */} + + + + + {localize('com_nav_chat_enter_your_passphrase')} + + +
+ + +
+
+
+ + ); +}; + +export default UserKeysSettings; \ No newline at end of file diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 60f65eeeec..4f8f94682f 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -897,7 +897,7 @@ export const useUploadAssistantAvatarMutation = ( unknown // context > => { return useMutation([MutationKeys.assistantAvatarUpload], { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) => dataService.uploadAssistantAvatar(variables), ...(options || {}), @@ -1068,3 +1068,24 @@ export const useAcceptTermsMutation = ( onMutate: options?.onMutate, }); }; + +export const useSetUserEncryptionMutation = ( + options?: { + onSuccess?: ( + data: t.UpdateUserEncryptionResponse, + variables: t.UpdateUserEncryptionRequest, + context?: unknown + ) => void; + onError?: ( + error: unknown, + variables: t.UpdateUserEncryptionRequest, + context?: unknown + ) => void; + } +): UseMutationResult => { + return useMutation([MutationKeys.updateUserEncryption], { + mutationFn: (variables: t.UpdateUserEncryptionRequest) => + dataService.updateUserEncryption(variables), + ...(options || {}), + }); +}; \ No newline at end of file diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1daa6794d6..2a45e86f52 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -531,6 +531,11 @@ "com_ui_controls": "Controls", "com_ui_copied": "Copied!", "com_ui_copied_to_clipboard": "Copied to clipboard", + "com_nav_chat_encryption_settings": "Encryption Settings", + "com_nav_chat_change_passphrase": "Change Passphrase", + "com_nav_chat_enter_your_passphrase": "Enter your passphrase", + "com_nav_chat_passphrase_placeholder": "Type your encryption passphrase here...", + "com_nav_chat_current_public_key": "Current Public Key", "com_ui_copy_code": "Copy code", "com_ui_copy_link": "Copy link", "com_ui_copy_to_clipboard": "Copy to clipboard", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 27cc221d72..ba988adf94 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -237,3 +237,5 @@ export const addTagToConversation = (conversationId: string) => export const userTerms = () => '/api/user/terms'; export const acceptUserTerms = () => '/api/user/terms/accept'; export const banner = () => '/api/banner'; + +export const encryption = () => '/api/user/encryption'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 5af00fdcb9..b79a086dd5 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -774,3 +774,9 @@ export function acceptTerms(): Promise { export function getBanner(): Promise { return request.get(endpoints.banner()); } + +export const updateUserEncryption = ( + payload: t.UpdateUserEncryptionRequest, +): Promise => { + return request.put(endpoints.encryption(), payload); +}; \ No newline at end of file diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index c1e0c24557..dfa92d0847 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -67,4 +67,5 @@ export enum MutationKeys { deleteAgentAction = 'deleteAgentAction', deleteUser = 'deleteUser', updateRole = 'updateRole', + updateUserEncryption = 'updateUserEncryption', } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index bf31a48cc0..30cade75e6 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -111,6 +111,10 @@ export type TUser = { plugins?: string[]; createdAt: string; updatedAt: string; + encryptionPublicKey?: string; + encryptedPrivateKey?: string; // Encrypted as a Base64 string + encryptionSalt?: string; // Base64 encoded salt used for PBKDF2 + encryptionIV?: string; // Base64 encoded IV for AES-GCM }; export type TGetConversationsResponse = { @@ -473,3 +477,21 @@ export type TAcceptTermsResponse = { }; export type TBannerResponse = TBanner | null; + +/** + * Request type for updating user encryption keys. + */ +export type UpdateUserEncryptionRequest = { + encryptionPublicKey: string; + encryptedPrivateKey: string; + encryptionSalt: string; + encryptionIV: string; +}; + +/** + * Response type for updating user encryption keys. + */ +export type UpdateUserEncryptionResponse = { + success: boolean; + message?: string; +}; \ No newline at end of file