diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 61b39a8f6de6..727bce39b241 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1,4 +1,3 @@ -const crypto = require('crypto'); const fetch = require('node-fetch'); const { supportsBalanceCheck, @@ -9,7 +8,7 @@ const { ErrorTypes, Constants, } = require('librechat-data-provider'); -const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models'); +const { getMessages, saveMessage, updateMessage, saveConvo, getConvo, getUserById } = require('~/models'); const { addSpaceIfNeeded, isEnabled } = require('~/server/utils'); const { truncateToolCallOutputs } = require('./prompts'); const checkBalance = require('~/models/checkBalance'); @@ -17,6 +16,48 @@ const { getFiles } = require('~/models/File'); const TextStream = require('./TextStream'); const { logger } = require('~/config'); +let crypto; +try { + crypto = require('crypto'); +} catch (err) { + logger.error('[AskController] crypto support is disabled!', err); +} + +/** + * Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key. + * @param {string} plainText - The plaintext to encrypt. + * @param {string} pemPublicKey - The RSA public key in PEM format. + * @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey. + */ +function encryptText(plainText, pemPublicKey) { + // Generate a random 256-bit AES key and a 12-byte IV. + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + + // Encrypt the plaintext using AES-256-GCM. + const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv); + let ciphertext = cipher.update(plainText, 'utf8', 'base64'); + ciphertext += cipher.final('base64'); + const authTag = cipher.getAuthTag().toString('base64'); + + // Encrypt the AES key using the user's RSA public key. + const encryptedKey = crypto.publicEncrypt( + { + key: pemPublicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + aesKey, + ).toString('base64'); + + return { + ciphertext, + iv: iv.toString('base64'), + authTag, + encryptedKey, + }; +} + class BaseClient { constructor(apiKey, options = {}) { this.apiKey = apiKey; @@ -849,18 +890,64 @@ class BaseClient { * @param {string | null} user */ async saveMessageToDatabase(message, endpointOptions, user = null) { - if (this.user && user !== this.user) { + // Normalize the user information: + // If "user" is an object, use it; otherwise, if a string is passed use req.user (if available) + const currentUser = + user && typeof user === 'object' + ? user + : (this.options.req && this.options.req.user + ? this.options.req.user + : { id: user }); + const currentUserId = currentUser.id || currentUser; + + // Check if the client’s stored user matches the current user. + // (this.user might have been set earlier in setMessageOptions) + const storedUserId = + this.user && typeof this.user === 'object' ? this.user.id : this.user; + if (storedUserId && currentUserId && storedUserId !== currentUserId) { throw new Error('User mismatch.'); } + // console.log('User ID:', currentUserId); + + const dbUser = await getUserById(currentUserId, 'encryptionPublicKey'); + + // --- NEW ENCRYPTION BLOCK: Encrypt AI response if encryptionPublicKey exists --- + if (dbUser.encryptionPublicKey && message && message.text) { + try { + // Rebuild the PEM format if necessary. + const pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${dbUser.encryptionPublicKey + .match(/.{1,64}/g) + .join('\n')}\n-----END PUBLIC KEY-----`; + const { ciphertext, iv, authTag, encryptedKey } = encryptText( + message.text, + pemPublicKey, + ); + message.text = ciphertext; + message.iv = iv; + message.authTag = authTag; + message.encryptedKey = encryptedKey; + logger.debug('[BaseClient.saveMessageToDatabase] Encrypted message text'); + } catch (err) { + logger.error('[BaseClient.saveMessageToDatabase] Error encrypting message text', err); + } + } + // --- End Encryption Block --- + + // Build update parameters including encryption fields. + const updateParams = { + ...message, + endpoint: this.options.endpoint, + unfinished: false, + user: currentUserId, // store the user id (ensured to be a string) + iv: message.iv ?? null, + authTag: message.authTag ?? null, + encryptedKey: message.encryptedKey ?? null, + }; + const savedMessage = await saveMessage( this.options.req, - { - ...message, - endpoint: this.options.endpoint, - unfinished: false, - user, - }, + updateParams, { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' }, ); @@ -1149,4 +1236,4 @@ class BaseClient { } } -module.exports = BaseClient; +module.exports = BaseClient; \ No newline at end of file diff --git a/api/models/Message.js b/api/models/Message.js index e651b20ad0a9..bdeef05718db 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -2,6 +2,7 @@ const { z } = require('zod'); const Message = require('./schema/messageSchema'); const { logger } = require('~/config'); +// Validate conversation ID as a UUID (if your conversation IDs follow UUID format) const idSchema = z.string().uuid(); /** @@ -28,8 +29,11 @@ const idSchema = z.string().uuid(); * @param {string} [params.plugin] - Plugin associated with the message. * @param {string[]} [params.plugins] - An array of plugins associated with the message. * @param {string} [params.model] - The model used to generate the message. - * @param {Object} [metadata] - Additional metadata for this operation - * @param {string} [metadata.context] - The context of the operation + * @param {string} [params.iv] - (Optional) Base64-encoded initialization vector for encryption. + * @param {string} [params.authTag] - (Optional) Base64-encoded authentication tag from AES-GCM. + * @param {string} [params.encryptedKey] - (Optional) Base64-encoded AES key encrypted with RSA. + * @param {Object} [metadata] - Additional metadata for this operation. + * @param {string} [metadata.context] - The context of the operation. * @returns {Promise} The updated or newly inserted message document. * @throws {Error} If there is an error in saving the message. */ @@ -51,6 +55,9 @@ async function saveMessage(req, params, metadata) { ...params, user: req.user.id, messageId: params.newMessageId || params.messageId, + iv: params.iv ?? null, + authTag: params.authTag ?? null, + encryptedKey: params.encryptedKey ?? null, }; if (req?.body?.isTemporary) { @@ -90,7 +97,12 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) { const bulkOps = messages.map((message) => ({ updateOne: { filter: { messageId: message.messageId }, - update: message, + update: { + ...message, + iv: message.iv ?? null, + authTag: message.authTag ?? null, + encryptedKey: message.encryptedKey ?? null, + }, timestamps: !overrideTimestamp, upsert: true, }, @@ -119,14 +131,7 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) { * @returns {Promise} The updated or newly inserted message document. * @throws {Error} If there is an error in saving the message. */ -async function recordMessage({ - user, - endpoint, - messageId, - conversationId, - parentMessageId, - ...rest -}) { +async function recordMessage({ user, endpoint, messageId, conversationId, parentMessageId, ...rest }) { try { // No parsing of convoId as may use threadId const message = { @@ -136,6 +141,9 @@ async function recordMessage({ conversationId, parentMessageId, ...rest, + iv: rest.iv ?? null, + authTag: rest.authTag ?? null, + encryptedKey: rest.encryptedKey ?? null, }; return await Message.findOneAndUpdate({ user, messageId }, message, { @@ -190,12 +198,15 @@ async function updateMessageText(req, { messageId, text }) { async function updateMessage(req, message, metadata) { try { const { messageId, ...update } = message; + // Ensure encryption fields are explicitly updated (if provided) + update.iv = update.iv ?? null; + update.authTag = update.authTag ?? null; + update.encryptedKey = update.encryptedKey ?? null; + const updatedMessage = await Message.findOneAndUpdate( { messageId, user: req.user.id }, update, - { - new: true, - }, + { new: true }, ); if (!updatedMessage) { @@ -225,11 +236,11 @@ async function updateMessage(req, message, metadata) { * * @async * @function deleteMessagesSince - * @param {Object} params - The parameters object. * @param {Object} req - The request object. + * @param {Object} params - The parameters object. * @param {string} params.messageId - The unique identifier for the message. * @param {string} params.conversationId - The identifier of the conversation. - * @returns {Promise} The number of deleted messages. + * @returns {Promise} The number of deleted messages. * @throws {Error} If there is an error in deleting messages. */ async function deleteMessagesSince(req, { messageId, conversationId }) { @@ -263,7 +274,6 @@ async function getMessages(filter, select) { if (select) { return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean(); } - return await Message.find(filter).sort({ createdAt: 1 }).lean(); } catch (err) { logger.error('Error getting messages:', err); @@ -281,10 +291,7 @@ async function getMessages(filter, select) { */ async function getMessage({ user, messageId }) { try { - return await Message.findOne({ - user, - messageId, - }).lean(); + return await Message.findOne({ user, messageId }).lean(); } catch (err) { logger.error('Error getting message:', err); throw err; diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index be7115529556..a8683b8a3079 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -137,6 +137,18 @@ const messageSchema = mongoose.Schema( expiredAt: { type: Date, }, + iv: { + type: String, + default: null, + }, + authTag: { + type: String, + default: null, + }, + encryptedKey: { + type: String, + default: null, + }, }, { timestamps: true }, ); diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index bebc7fea1e59..b61a0faa09a9 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 encryption public key + * @property {string} [encryptedPrivateKey] - The user's encrypted private key + * @property {string} [encryptionSalt] - The salt used for key derivation (e.g., PBKDF2) + * @property {string} [encryptionIV] - The IV used for encrypting the private key * @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) */ @@ -143,6 +147,22 @@ const userSchema = mongoose.Schema( type: Boolean, default: false, }, + encryptionPublicKey: { + type: String, + default: null, + }, + encryptedPrivateKey: { + type: String, + default: null, + }, + encryptionSalt: { + type: String, + default: null, + }, + encryptionIV: { + type: String, + default: null, + }, }, { timestamps: true }, diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index 2df6f34ede59..d0cde5f5d2b1 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -1,9 +1,59 @@ const { getResponseSender, Constants } = require('librechat-data-provider'); const { createAbortController, handleAbortError } = require('~/server/middleware'); const { sendMessage, createOnProgress } = require('~/server/utils'); -const { saveMessage } = require('~/models'); +const { saveMessage, getUserById } = require('~/models'); const { logger } = require('~/config'); +let crypto; +try { + crypto = require('crypto'); +} catch (err) { + logger.error('[AskController] crypto support is disabled!', err); +} + +/** + * Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key. + * @param {string} plainText - The plaintext to encrypt. + * @param {string} pemPublicKey - The RSA public key in PEM format. + * @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey. + */ +function encryptText(plainText, pemPublicKey) { + // Generate a random 256-bit AES key and a 12-byte IV. + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + + // Encrypt the plaintext using AES-256-GCM. + const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv); + let ciphertext = cipher.update(plainText, 'utf8', 'base64'); + ciphertext += cipher.final('base64'); + const authTag = cipher.getAuthTag().toString('base64'); + + // Encrypt the AES key using the user's RSA public key. + const encryptedKey = crypto.publicEncrypt( + { + key: pemPublicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + aesKey, + ).toString('base64'); + + return { + ciphertext, + iv: iv.toString('base64'), + authTag, + encryptedKey, + }; +} + +/** + * AskController + * - Initializes the client. + * - Obtains the response from the language model. + * - Retrieves the full user record (to get encryption parameters). + * - If the user has encryption enabled (i.e. encryptionPublicKey is provided), + * encrypts both the request (userMessage) and the response before saving. + */ const AskController = async (req, res, next, initializeClient, addTitle) => { let { text, @@ -32,7 +82,22 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { modelDisplayLabel, }); const newConvo = !conversationId; - const user = req.user.id; + const userId = req.user.id; // User ID from authentication + + // Retrieve full user record from DB (including encryption parameters) + const dbUser = await getUserById(userId, 'encryptionPublicKey encryptedPrivateKey encryptionSalt encryptionIV'); + + // Build clientOptions including the encryptionPublicKey (if available) + const clientOptions = { + encryptionPublicKey: dbUser?.encryptionPublicKey, + }; + + // Rebuild PEM format if encryptionPublicKey is available + let pemPublicKey = null; + if (clientOptions.encryptionPublicKey && clientOptions.encryptionPublicKey.trim() !== '') { + const pubKeyBase64 = clientOptions.encryptionPublicKey; + pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyBase64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`; + } const getReqData = (data = {}) => { for (let key in data) { @@ -52,11 +117,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { }; let getText; - try { - const { client } = await initializeClient({ req, res, endpointOption }); + // Pass clientOptions (which includes encryptionPublicKey) along with other parameters to initializeClient + const { client } = await initializeClient({ req, res, endpointOption, ...clientOptions }); const { onProgress: progressCallback, getPartialText } = createOnProgress(); - getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText; const getAbortData = () => ({ @@ -74,20 +138,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { res.on('close', () => { logger.debug('[AskController] Request closed'); - if (!abortController) { - return; - } else if (abortController.signal.aborted) { - return; - } else if (abortController.requestCompleted) { - return; - } - + if (!abortController) { return; } + if (abortController.signal.aborted || abortController.requestCompleted) { return; } abortController.abort(); logger.debug('[AskController] Request aborted on close'); }); const messageOptions = { - user, + user: userId, parentMessageId, conversationId, overrideParentMessageId, @@ -95,16 +153,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { onStart, abortController, progressCallback, - progressOptions: { - res, - // parentMessageId: overrideParentMessageId || userMessageId, - }, + progressOptions: { res }, }; - /** @type {TMessage} */ + // Get the response from the language model client. let response = await client.sendMessage(text, messageOptions); response.endpoint = endpointOption.endpoint; + // Ensure the conversation has a title. const { conversation = {} } = await client.responsePromise; conversation.title = conversation && !conversation.title ? null : conversation?.title || 'New Chat'; @@ -115,6 +171,35 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { delete userMessage.image_urls; } + // --- Encrypt the user message if encryption is enabled --- + if (pemPublicKey && userMessage && userMessage.text) { + try { + const { ciphertext, iv, authTag, encryptedKey } = encryptText(userMessage.text, pemPublicKey); + userMessage.text = ciphertext; + userMessage.iv = iv; + userMessage.authTag = authTag; + userMessage.encryptedKey = encryptedKey; + logger.debug('[AskController] User message encrypted.'); + } catch (encError) { + logger.error('[AskController] Error encrypting user message:', encError); + } + } + + // --- Encrypt the AI response if encryption is enabled --- + if (pemPublicKey && response.text) { + try { + const { ciphertext, iv, authTag, encryptedKey } = encryptText(response.text, pemPublicKey); + response.text = ciphertext; + response.iv = iv; + response.authTag = authTag; + response.encryptedKey = encryptedKey; + logger.debug('[AskController] Response message encrypted.'); + } catch (encError) { + logger.error('[AskController] Error encrypting response message:', encError); + } + } + // --- End Encryption Branch --- + if (!abortController.signal.aborted) { sendMessage(res, { final: true, @@ -128,15 +213,15 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { if (!client.savedMessageIds.has(response.messageId)) { await saveMessage( req, - { ...response, user }, - { context: 'api/server/controllers/AskController.js - response end' }, + { ...response, user: userId }, + { context: 'AskController - response end' }, ); } } if (!client.skipSaveUserMessage) { await saveMessage(req, userMessage, { - context: 'api/server/controllers/AskController.js - don\'t skip saving user message', + context: 'AskController - save user message', }); } @@ -156,9 +241,9 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { messageId: responseMessageId, parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId, }).catch((err) => { - logger.error('[AskController] Error in `handleAbortError`', err); + logger.error('[AskController] Error in handleAbortError', err); }); } }; -module.exports = AskController; +module.exports = AskController; \ No newline at end of file diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index a331b8daae28..414e90f373ce 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'); @@ -164,6 +165,37 @@ const resendVerificationController = async (req, res) => { } }; +const updateUserEncryptionController = async (req, res) => { + try { + const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body; + + // Allow disabling encryption by passing null for all fields. + const allNull = encryptionPublicKey === null && encryptedPrivateKey === null && encryptionSalt === null && encryptionIV === null; + const allPresent = encryptionPublicKey && encryptedPrivateKey && encryptionSalt && encryptionIV; + + if (!allNull && !allPresent) { + return res.status(400).json({ message: 'Missing encryption parameters.' }); + } + + // Update the user record with the provided encryption parameters (or null to disable) + const updatedUser = await updateUser(req.user.id, { + encryptionPublicKey: encryptionPublicKey || null, + encryptedPrivateKey: encryptedPrivateKey || null, + encryptionSalt: encryptionSalt || null, + encryptionIV: encryptionIV || null, + }); + + 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, @@ -172,4 +204,5 @@ module.exports = { verifyEmailController, updateUserPluginsController, resendVerificationController, + updateUserEncryptionController, }; diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 34d28fd937cc..a13836f3adfa 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/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 1547a01d8042..433748ce1ff3 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -1,4 +1,4 @@ -import { memo, Suspense, useMemo } from 'react'; +import React, { memo, Suspense, useMemo, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import type { TMessage } from 'librechat-data-provider'; import type { TMessageContentProps, TDisplayProps } from '~/common'; @@ -13,6 +13,77 @@ import Container from './Container'; import Markdown from './Markdown'; import { cn } from '~/utils'; import store from '~/store'; +import { useAuthContext } from '~/hooks/AuthContext'; + +/** + * Helper: Converts a base64 string to an ArrayBuffer. + */ +const base64ToArrayBuffer = (base64: string): ArrayBuffer => { + const binaryStr = window.atob(base64); + const len = binaryStr.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + return bytes.buffer; +}; + +/** + * Helper: Decrypts an encrypted chat message using the provided RSA private key. + * Expects the message object to have: text (ciphertext), iv, authTag, and encryptedKey. + */ +async function decryptChatMessage( + msg: { text: string; iv: string; authTag: string; encryptedKey: string }, + privateKey: CryptoKey +): Promise { + // Convert base64 values to ArrayBuffers. + const ciphertextBuffer = base64ToArrayBuffer(msg.text); + const ivBuffer = new Uint8Array(base64ToArrayBuffer(msg.iv)); + const authTagBuffer = new Uint8Array(base64ToArrayBuffer(msg.authTag)); + const encryptedKeyBuffer = base64ToArrayBuffer(msg.encryptedKey); + + // Decrypt the AES key using RSA-OAEP. + let aesKeyRaw: ArrayBuffer; + try { + aesKeyRaw = await window.crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + privateKey, + encryptedKeyBuffer + ); + } catch (err) { + console.error('Failed to decrypt AES key:', err); + throw err; + } + + // Import the AES key. + const aesKey = await window.crypto.subtle.importKey( + 'raw', + aesKeyRaw, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + // Combine ciphertext and auth tag (Web Crypto expects them appended). + const ciphertextBytes = new Uint8Array(ciphertextBuffer); + const combined = new Uint8Array(ciphertextBytes.length + authTagBuffer.length); + combined.set(ciphertextBytes); + combined.set(authTagBuffer, ciphertextBytes.length); + + // Decrypt the message using AES-GCM. + let decryptedBuffer: ArrayBuffer; + try { + decryptedBuffer = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuffer }, + aesKey, + combined.buffer + ); + } catch (err) { + console.error('Failed to decrypt message:', err); + throw err; + } + return new TextDecoder().decode(decryptedBuffer); +} export const ErrorMessage = ({ text, @@ -40,12 +111,7 @@ export const ErrorMessage = ({ > -
+
{localize('com_ui_error_connection')}
@@ -58,10 +124,7 @@ export const ErrorMessage = ({
@@ -69,41 +132,65 @@ export const ErrorMessage = ({ ); }; -const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { +const DisplayMessage = ({ text, isCreatedByUser, message, showCursor, className = '' }: TDisplayProps) => { const { isSubmitting, latestMessage } = useChatContext(); + const { user } = useAuthContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); - const showCursorState = useMemo( - () => showCursor === true && isSubmitting, - [showCursor, isSubmitting], - ); - const isLatestMessage = useMemo( - () => message.messageId === latestMessage?.messageId, - [message.messageId, latestMessage?.messageId], - ); + const showCursorState = useMemo(() => showCursor === true && isSubmitting, [showCursor, isSubmitting]); + const isLatestMessage = useMemo(() => message.messageId === latestMessage?.messageId, [message.messageId, latestMessage?.messageId]); + + // State to hold the final text to display (decrypted if needed) + const [displayText, setDisplayText] = useState(text); + const [decryptionError, setDecryptionError] = useState(null); + + useEffect(() => { + if (message.encryptedKey && user?.decryptedPrivateKey) { + // Attempt to decrypt the message using our helper. + decryptChatMessage( + { + text: message.text, + iv: message.iv, + authTag: message.authTag, + encryptedKey: message.encryptedKey, + }, + user.decryptedPrivateKey + ) + .then((plainText) => { + setDisplayText(plainText); + setDecryptionError(null); + }) + .catch((err) => { + console.error('Error decrypting message:', err); + setDecryptionError('Decryption error'); + setDisplayText(''); + }); + } else { + // If no encryption metadata or no private key, display plain text. + setDisplayText(text); + setDecryptionError(null); + } + }, [text, message, user]); let content: React.ReactElement; if (!isCreatedByUser) { - content = ( - - ); + content = ; } else if (enableUserMsgMarkdown) { - content = ; + content = ; } else { - content = <>{text}; + content = <>{displayText}; } return ( -
- {content} +
+ {decryptionError ? {decryptionError} : content}
); @@ -162,15 +249,10 @@ const MessageContent = ({ {thinkingContent.length > 0 && ( {thinkingContent} )} - + {unfinishedMessage} ); }; -export default memo(MessageContent); +export default memo(MessageContent); \ No newline at end of file diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx index 1fd2e5c7bd45..7bd100938f20 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 000000000000..03368766ab6c --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Chat/EncryptionPassphrase.tsx @@ -0,0 +1,313 @@ +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 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, + 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; +}; + +/** + * Decrypts the user's encrypted private key using the provided passphrase. + */ +async function decryptUserPrivateKey( + encryptedPrivateKeyBase64: string, + saltBase64: string, + ivBase64: string, + passphrase: string +): Promise { + // Convert salt and IV to Uint8Array. + const salt = new Uint8Array(window.atob(saltBase64).split('').map(c => c.charCodeAt(0))); + const iv = new Uint8Array(window.atob(ivBase64).split('').map(c => c.charCodeAt(0))); + + // Derive symmetric key from passphrase. + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'] + ); + const symmetricKey = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['decrypt'] + ); + + // Decrypt the encrypted private key. + const encryptedPrivateKeyBuffer = new Uint8Array( + window.atob(encryptedPrivateKeyBase64) + .split('') + .map(c => c.charCodeAt(0)) + ); + const decryptedBuffer = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + symmetricKey, + encryptedPrivateKeyBuffer + ); + // Import the decrypted key as a CryptoKey. + return await window.crypto.subtle.importKey( + 'pkcs8', + decryptedBuffer, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'] + ); +} + +const UserKeysSettings: FC = () => { + const localize = useLocalize(); + const { user } = useAuthContext(); + const setUser = useSetRecoilState(store.user); + const setDecryptedPrivateKey = useSetRecoilState(store.decryptedPrivateKey); + 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' }); + }, + }); + + 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 the 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 = window.btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer))); + const privateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer))); + console.debug('New public key:', publicKeyBase64); + console.debug('New private key (plaintext):', privateKeyBase64); + + // Generate a salt (16 bytes) and IV (12 bytes) for AES-GCM. + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + // Derive a symmetric key from the passphrase using PBKDF2. + const derivedKey = await deriveKey(passphrase, salt); + + // Encrypt the private key using AES-GCM. + const encoder = new TextEncoder(); + const privateKeyBytes = encoder.encode(privateKeyBase64); + const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + derivedKey, + privateKeyBytes + ); + const encryptedPrivateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer))); + + // Convert salt and IV to Base64 strings. + const saltBase64 = window.btoa(String.fromCharCode(...salt)); + const ivBase64 = window.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 disableEncryption = async (): Promise => { + try { + await setEncryption({ + encryptionPublicKey: null, + encryptedPrivateKey: null, + encryptionSalt: null, + encryptionIV: null, + }); + showToast({ message: localize('com_ui_upload_success') }); + setUser((prev) => ({ + ...prev, + encryptionPublicKey: null, + encryptedPrivateKey: null, + encryptionSalt: null, + encryptionIV: null, + }) as TUser); + setDecryptedPrivateKey(null); + } catch (error) { + console.error('Error disabling encryption:', error); + } + }; + + const handleSubmit = async (): Promise => { + const newEncryption = await activateEncryption(); + if (newEncryption) { + try { + await setEncryption(newEncryption); + showToast({ message: localize('com_ui_upload_success') }); + setUser((prev) => ({ + ...prev, + ...newEncryption, + }) as TUser); + // Decrypt the private key and store it in the atom. + const decryptedKey = await decryptUserPrivateKey( + newEncryption.encryptedPrivateKey, + newEncryption.encryptionSalt, + newEncryption.encryptionIV, + passphrase + ); + setDecryptedPrivateKey(decryptedKey); + } catch (error) { + console.error('Mutation error:', error); + } + } + setDialogOpen(false); + setPassphrase(''); + }; + + const handleInputChange = (e: ChangeEvent): void => { + setPassphrase(e.target.value); + }; + + return ( + <> +
+
+ + {localize('com_nav_chat_encryption_settings')} +
+
+ + {user?.encryptionPublicKey && ( + + )} +
+
+ {user?.encryptionPublicKey && ( +
+ {localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}... +
+ )} + + + + {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 60f65eeeec23..4f8f94682f7e 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/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index a52928caadcd..4ff76377efba 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -234,6 +234,6 @@ export default function useSSE( sse.dispatchEvent(e); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submission]); } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 8956323ec4ec..f5a84639a65d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -551,6 +551,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/client/src/store/user.ts b/client/src/store/user.ts index ac771aea0608..1c07b6be5603 100644 --- a/client/src/store/user.ts +++ b/client/src/store/user.ts @@ -11,7 +11,14 @@ const availableTools = atom>({ default: {}, }); +// New atom to hold the decrypted private key (as a CryptoKey) +const decryptedPrivateKey = atom({ + key: 'decryptedPrivateKey', + default: null, +}); + export default { user, availableTools, -}; + decryptedPrivateKey, +}; \ No newline at end of file diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 142ed9ba2021..f9e757b9a180 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -238,10 +238,14 @@ export const userTerms = () => '/api/user/terms'; export const acceptUserTerms = () => '/api/user/terms/accept'; export const banner = () => '/api/banner'; + +export const encryption = () => '/api/user/encryption'; + // Two-Factor Endpoints export const enableTwoFactor = () => '/api/auth/2fa/enable'; export const verifyTwoFactor = () => '/api/auth/2fa/verify'; export const confirmTwoFactor = () => '/api/auth/2fa/confirm'; export const disableTwoFactor = () => '/api/auth/2fa/disable'; export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate'; -export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp'; \ No newline at end of file +export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp'; + diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 78700e7419a6..1489277304f9 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -775,6 +775,14 @@ export function getBanner(): Promise { return request.get(endpoints.banner()); } + +export const updateUserEncryption = ( + payload: t.UpdateUserEncryptionRequest, +): Promise => { + return request.put(endpoints.encryption(), payload); +}; + + export function enableTwoFactor(): Promise { return request.get(endpoints.enableTwoFactor()); } @@ -803,4 +811,4 @@ export function verifyTwoFactorTemp( payload: t.TVerify2FATempRequest, ): Promise { return request.post(endpoints.verifyTwoFactorTemp(), payload); -} \ No newline at end of file +} diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index fd5ee95087a6..b52e82a0d578 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -67,6 +67,7 @@ export enum MutationKeys { deleteAgentAction = 'deleteAgentAction', deleteUser = 'deleteUser', updateRole = 'updateRole', + updateUserEncryption = 'updateUserEncryption', enableTwoFactor = 'enableTwoFactor', verifyTwoFactor = 'verifyTwoFactor', } diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 533d6ffc373c..5ee4bd1c0bbb 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -496,14 +496,17 @@ export const tMessageSchema = z.object({ thread_id: z.string().optional(), /* frontend components */ iconURL: z.string().nullable().optional(), + iv: z.string().nullable().optional(), + authTag: z.string().nullable().optional(), + encryptedKey: z.string().nullable().optional(), }); export type TAttachmentMetadata = { messageId: string; toolCallId: string }; export type TAttachment = | (TFile & TAttachmentMetadata) | (Pick & { - expiresAt: number; - } & TAttachmentMetadata); + expiresAt: number; +} & TAttachmentMetadata); export type TMessage = z.input & { children?: TMessage[]; @@ -515,6 +518,7 @@ export type TMessage = z.input & { siblingIndex?: number; attachments?: TAttachment[]; clientTimestamp?: string; + messageEncryptionIV?: string; }; export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => { @@ -768,11 +772,11 @@ export const googleSchema = tConversationSchema .catch(() => ({})); /** - * TODO: Map the following fields: - - presence_penalty -> presencePenalty - - frequency_penalty -> frequencyPenalty - - stop -> stopSequences - */ + * TODO: Map the following fields: + - presence_penalty -> presencePenalty + - frequency_penalty -> frequencyPenalty + - stop -> stopSequences + */ export const googleGenConfigSchema = z .object({ maxOutputTokens: coerceNumber.optional(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 677190126706..03b70aa65359 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -41,11 +41,11 @@ export type TEndpointOption = { export type TPayload = Partial & Partial & { - isContinued: boolean; - conversationId: string | null; - messages?: TMessages; - isTemporary: boolean; - }; + isContinued: boolean; + conversationId: string | null; + messages?: TMessages; + isTemporary: boolean; +}; export type TSubmission = { artifacts?: string; @@ -115,6 +115,7 @@ export type TUser = { role: string; provider: string; plugins?: string[]; + decryptedPrivateKey?: CryptoKey | string; backupCodes?: TBackupCode[]; createdAt: string; updatedAt: string; @@ -530,3 +531,21 @@ export type TAcceptTermsResponse = { }; export type TBannerResponse = TBanner | null; + +/** + * Request type for updating user encryption keys. + */ +export type UpdateUserEncryptionRequest = { + encryptionPublicKey: string | null; + encryptedPrivateKey: string | null; + encryptionSalt: string | null; + encryptionIV: string | null; +}; + +/** + * Response type for updating user encryption keys. + */ +export type UpdateUserEncryptionResponse = { + success: boolean; + message?: string; +}; \ No newline at end of file