Compare commits

...

10 Commits

Author SHA1 Message Date
Ruben Talstra
40e59bc55c Merge branch 'main' into feat/E2EE 2025-03-05 10:50:49 +01:00
Ruben Talstra
94f0d1cb41 refactor: decrypting the encrypted private key to decrypt the messages. 2025-02-16 17:54:06 +01:00
Ruben Talstra
d37cc1cf4d refactor: works. now fixing to decrypt the text in the UI. 2025-02-16 16:58:59 +01:00
Ruben Talstra
7346d20224 refactor: request is encrypted. response from AI is still saved in plaintext but from the stream the final response is encrypted. 2025-02-16 11:56:40 +01:00
Ruben Talstra
0cc0e5d287 Merge branch 'main' into feat/E2EE 2025-02-16 10:23:22 +01:00
Ruben Talstra
d01674a4c6 refactor: removed unused file 2025-02-16 09:52:44 +01:00
Ruben Talstra
d4621c3ea8 Merge remote-tracking branch 'origin/feat/E2EE' into feat/E2EE
# Conflicts:
#	client/src/components/Nav/SettingsTabs/Chat/EncryptionPassphrase.tsx
#	client/src/hooks/SSE/useSSE.ts
#	packages/data-provider/src/types.ts
2025-02-16 09:48:56 +01:00
Ruben Talstra
94d32906f1 refactor: fully working E2EE
small issue to fix. when full response is received it replaces the text with the text from the DB. and then the decryption is not yet implement.
2025-02-16 09:48:08 +01:00
Ruben Talstra
606fea044a refactor: creating a starting point for E2EE 2025-02-15 23:04:26 +01:00
Ruben Talstra
18d019d8b3 feat: started with proper E2EE ;) 2025-02-15 21:26:40 +01:00
19 changed files with 828 additions and 114 deletions

View File

@@ -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 clients 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;

View File

@@ -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<TMessage>} 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<Object>} 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<Number>} The number of deleted messages.
* @returns {Promise<number>} 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;

View File

@@ -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 },
);

View File

@@ -27,6 +27,10 @@ const { SystemRoles } = require('librechat-data-provider');
* @property {Array} [plugins=[]] - List of plugins used by the user
* @property {Array.<MongoSession>} [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 },

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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<string> {
// 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 = ({
>
<DelayedRender delay={5500}>
<Container message={message}>
<div
className={cn(
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
>
<div className={cn('rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}>
{localize('com_ui_error_connection')}
</div>
</Container>
@@ -58,10 +124,7 @@ export const ErrorMessage = ({
<div
role="alert"
aria-live="assertive"
className={cn(
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
className,
)}
className={cn('rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}
>
<Error text={text} />
</div>
@@ -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<string>(text);
const [decryptionError, setDecryptionError] = useState<string | null>(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 = (
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
);
content = <Markdown content={displayText} showCursor={showCursorState} isLatestMessage={isLatestMessage} />;
} else if (enableUserMsgMarkdown) {
content = <MarkdownLite content={text} />;
content = <MarkdownLite content={displayText} />;
} else {
content = <>{text}</>;
content = <>{displayText}</>;
}
return (
<Container message={message}>
<div
className={cn(
isSubmitting ? 'submitting' : '',
showCursorState && !!text.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
)}
>
{content}
<div className={cn(
isSubmitting ? 'submitting' : '',
showCursorState && !!displayText.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words',
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
className
)}>
{decryptionError ? <span className="text-red-500">{decryptionError}</span> : content}
</div>
</Container>
);
@@ -162,15 +249,10 @@ const MessageContent = ({
{thinkingContent.length > 0 && (
<Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking>
)}
<DisplayMessage
key={`display-${messageId}`}
showCursor={showRegularCursor}
text={regularContent}
{...props}
/>
<DisplayMessage key={`display-${messageId}`} showCursor={showRegularCursor} text={regularContent} {...props} />
{unfinishedMessage}
</>
);
};
export default memo(MessageContent);
export default memo(MessageContent);

View File

@@ -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() {
<div className="pb-3">
<ScrollButton />
</div>
<div className="pb-3">
<EncryptionPassphrase />
</div>
<ForkSettings />
<div className="pb-3">
<ModularChat />

View File

@@ -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<CryptoKey> => {
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<CryptoKey> {
// 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<boolean>(false);
const [passphrase, setPassphrase] = useState<string>('');
// 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<void> => {
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<void> => {
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<HTMLInputElement>): void => {
setPassphrase(e.target.value);
};
return (
<>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Key className="flex w-[20px] h-[20px]" />
<span id="user-keys-label">{localize('com_nav_chat_encryption_settings')}</span>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
aria-label="Set/Change encryption keys"
onClick={() => setDialogOpen(true)}
data-testid="userKeysSettings"
>
<Lock className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_chat_change_passphrase')}</span>
</Button>
{user?.encryptionPublicKey && (
<Button
variant="outline"
aria-label="Disable encryption"
onClick={disableEncryption}
data-testid="disableEncryption"
>
<span>Disable Encryption</span>
</Button>
)}
</div>
</div>
{user?.encryptionPublicKey && (
<div className="pt-2 text-xs text-gray-500">
{localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}...
</div>
)}
<OGDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_chat_enter_your_passphrase')}</OGDialogTitle>
</OGDialogHeader>
<div className="p-4 flex flex-col gap-4">
<Input
type="password"
value={passphrase}
onChange={handleInputChange}
placeholder={localize('com_nav_chat_passphrase_placeholder')}
aria-label={localize('com_nav_chat_enter_your_passphrase')}
/>
<Button variant="outline" onClick={handleSubmit}>
{localize('com_ui_submit')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
</>
);
};
export default UserKeysSettings;

View File

@@ -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<t.UpdateUserEncryptionResponse, unknown, t.UpdateUserEncryptionRequest, unknown> => {
return useMutation([MutationKeys.updateUserEncryption], {
mutationFn: (variables: t.UpdateUserEncryptionRequest) =>
dataService.updateUserEncryption(variables),
...(options || {}),
});
};

View File

@@ -234,6 +234,6 @@ export default function useSSE(
sse.dispatchEvent(e);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [submission]);
}

View File

@@ -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",

View File

@@ -11,7 +11,14 @@ const availableTools = atom<Record<string, TPlugin>>({
default: {},
});
// New atom to hold the decrypted private key (as a CryptoKey)
const decryptedPrivateKey = atom<CryptoKey | null>({
key: 'decryptedPrivateKey',
default: null,
});
export default {
user,
availableTools,
};
decryptedPrivateKey,
};

View File

@@ -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';
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';

View File

@@ -775,6 +775,14 @@ export function getBanner(): Promise<t.TBannerResponse> {
return request.get(endpoints.banner());
}
export const updateUserEncryption = (
payload: t.UpdateUserEncryptionRequest,
): Promise<t.UpdateUserEncryptionResponse> => {
return request.put(endpoints.encryption(), payload);
};
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
return request.get(endpoints.enableTwoFactor());
}
@@ -803,4 +811,4 @@ export function verifyTwoFactorTemp(
payload: t.TVerify2FATempRequest,
): Promise<t.TVerify2FATempResponse> {
return request.post(endpoints.verifyTwoFactorTemp(), payload);
}
}

View File

@@ -67,6 +67,7 @@ export enum MutationKeys {
deleteAgentAction = 'deleteAgentAction',
deleteUser = 'deleteUser',
updateRole = 'updateRole',
updateUserEncryption = 'updateUserEncryption',
enableTwoFactor = 'enableTwoFactor',
verifyTwoFactor = 'verifyTwoFactor',
}

View File

@@ -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<TFile, 'filename' | 'filepath' | 'conversationId'> & {
expiresAt: number;
} & TAttachmentMetadata);
expiresAt: number;
} & TAttachmentMetadata);
export type TMessage = z.input<typeof tMessageSchema> & {
children?: TMessage[];
@@ -515,6 +518,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
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(),

View File

@@ -41,11 +41,11 @@ export type TEndpointOption = {
export type TPayload = Partial<TMessage> &
Partial<TEndpointOption> & {
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;
};