mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 10:20:15 +01:00
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.
This commit is contained in:
parent
18d019d8b3
commit
94d32906f1
11 changed files with 343 additions and 189 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -54,11 +54,6 @@ 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,
|
||||
},
|
||||
|
|
@ -142,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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,13 +23,6 @@ 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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,10 +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 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 {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)
|
||||
*/
|
||||
|
|
@ -136,26 +136,21 @@ const userSchema = mongoose.Schema(
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// --- New Fields for E2EE ---
|
||||
encryptionPublicKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
// Provided by the client after key generation.
|
||||
default: null,
|
||||
},
|
||||
encryptedPrivateKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
// The private key encrypted on the client with the user’s encryption passphrase.
|
||||
default: null,
|
||||
},
|
||||
encryptionSalt: {
|
||||
type: String,
|
||||
required: false,
|
||||
// Salt used for PBKDF2 when encrypting the private key.
|
||||
default: null,
|
||||
},
|
||||
encryptionIV: {
|
||||
type: String,
|
||||
required: false,
|
||||
// IV used for AES-GCM encryption of the private key.
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ const { sendMessage, createOnProgress } = require('~/server/utils');
|
|||
const { saveMessage } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
crypto = require('crypto');
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy] crypto support is disabled!', err);
|
||||
}
|
||||
|
||||
const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
let {
|
||||
text,
|
||||
|
|
@ -74,14 +81,8 @@ 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');
|
||||
});
|
||||
|
|
@ -95,10 +96,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||
onStart,
|
||||
abortController,
|
||||
progressCallback,
|
||||
progressOptions: {
|
||||
res,
|
||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
||||
},
|
||||
progressOptions: { res },
|
||||
};
|
||||
|
||||
/** @type {TMessage} */
|
||||
|
|
@ -115,6 +113,58 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
|||
delete userMessage.image_urls;
|
||||
}
|
||||
|
||||
// --- Encryption Branch ---
|
||||
// Only encrypt if the user has set up encryption (i.e. non-empty encryptionPublicKey)
|
||||
if (
|
||||
req.user.encryptionPublicKey &&
|
||||
req.user.encryptionPublicKey.trim() !== '' &&
|
||||
response.text &&
|
||||
crypto
|
||||
) {
|
||||
try {
|
||||
// Reconstruct the user's RSA public key in PEM format.
|
||||
const pubKeyBase64 = req.user.encryptionPublicKey;
|
||||
const pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyBase64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`;
|
||||
|
||||
// Generate a random 256-bit AES key and a 12-byte IV.
|
||||
const aesKey = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// Encrypt the response text using AES-GCM.
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||
let ciphertext = cipher.update(response.text, 'utf8', 'base64');
|
||||
ciphertext += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag().toString('base64');
|
||||
|
||||
// Encrypt the AES key using the client's RSA public key.
|
||||
let encryptedKey;
|
||||
try {
|
||||
encryptedKey = crypto.publicEncrypt(
|
||||
{
|
||||
key: pemPublicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
aesKey,
|
||||
).toString('base64');
|
||||
} catch (err) {
|
||||
logger.error('Error encrypting AES key:', err);
|
||||
throw new Error('Encryption failure');
|
||||
}
|
||||
|
||||
// Replace the plaintext response with the encrypted payload.
|
||||
response.text = ciphertext;
|
||||
response.iv = iv.toString('base64');
|
||||
response.authTag = authTag;
|
||||
response.encryptedKey = encryptedKey;
|
||||
logger.debug('[AskController] Response message encrypted.');
|
||||
} catch (encError) {
|
||||
logger.error('[AskController] Error during response encryption:', encError);
|
||||
// Optionally, you may choose to return plaintext if encryption fails.
|
||||
}
|
||||
}
|
||||
// --- End Encryption Branch ---
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
sendMessage(res, {
|
||||
final: true,
|
||||
|
|
|
|||
|
|
@ -167,17 +167,20 @@ const updateUserEncryptionController = async (req, res) => {
|
|||
try {
|
||||
const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body;
|
||||
|
||||
// Validate required parameters
|
||||
if (!encryptionPublicKey || !encryptedPrivateKey || !encryptionSalt || !encryptionIV) {
|
||||
// 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.' });
|
||||
}
|
||||
|
||||
// Use the helper function to update the user.
|
||||
// Update the user record with the provided encryption parameters (or null to disable)
|
||||
const updatedUser = await updateUser(req.user.id, {
|
||||
encryptionPublicKey,
|
||||
encryptedPrivateKey,
|
||||
encryptionSalt,
|
||||
encryptionIV,
|
||||
encryptionPublicKey: encryptionPublicKey || null,
|
||||
encryptedPrivateKey: encryptedPrivateKey || null,
|
||||
encryptionSalt: encryptionSalt || null,
|
||||
encryptionIV: encryptionIV || null,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<div className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
className
|
||||
)}>
|
||||
{decryptionError ? <span className="text-red-500">{decryptionError}</span> : content}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
|
@ -162,12 +249,7 @@ 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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,24 +15,17 @@ 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).
|
||||
/**
|
||||
* 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'))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// Derive an AES-GCM key from the passphrase using PBKDF2.
|
||||
/**
|
||||
* 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(
|
||||
|
|
@ -46,7 +39,7 @@ const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKe
|
|||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000, // Adjust as needed for security/performance trade-off
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
|
|
@ -60,22 +53,6 @@ const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKe
|
|||
return derivedKey;
|
||||
};
|
||||
|
||||
// Decrypt the encrypted private key using the derived key and IV.
|
||||
const decryptPrivateKey = async (
|
||||
derivedKey: CryptoKey,
|
||||
iv: Uint8Array,
|
||||
ciphertextBase64: string
|
||||
): Promise<string> => {
|
||||
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();
|
||||
|
|
@ -92,7 +69,15 @@ const UserKeysSettings: FC = () => {
|
|||
},
|
||||
});
|
||||
|
||||
// Activation/Update flow: Generate new keys and update user encryption fields.
|
||||
/**
|
||||
* Activation flow:
|
||||
* 1. Generate a new RSA-OAEP key pair.
|
||||
* 2. Export the public and private keys.
|
||||
* 3. Generate a random salt (16 bytes) and IV (12 bytes) for AES-GCM.
|
||||
* 4. Derive a symmetric key from the passphrase using PBKDF2.
|
||||
* 5. Encrypt the private key with AES-GCM.
|
||||
* 6. Return the base64-encoded encryption fields.
|
||||
*/
|
||||
const activateEncryption = async (): Promise<{
|
||||
encryptionPublicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
|
|
@ -122,22 +107,23 @@ const UserKeysSettings: FC = () => {
|
|||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
// Export public and private keys.
|
||||
|
||||
// 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 = btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer)));
|
||||
const privateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer)));
|
||||
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 and IV.
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16)); // 16 bytes salt
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12 bytes IV
|
||||
// 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.
|
||||
// Derive a symmetric key from the passphrase using PBKDF2.
|
||||
const derivedKey = await deriveKey(passphrase, salt);
|
||||
|
||||
// Encrypt the private key.
|
||||
// Encrypt the private key using AES-GCM.
|
||||
const encoder = new TextEncoder();
|
||||
const privateKeyBytes = encoder.encode(privateKeyBase64);
|
||||
const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt(
|
||||
|
|
@ -145,11 +131,11 @@ const UserKeysSettings: FC = () => {
|
|||
derivedKey,
|
||||
privateKeyBytes
|
||||
);
|
||||
const encryptedPrivateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
|
||||
const encryptedPrivateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
|
||||
|
||||
// Convert salt and IV to Base64.
|
||||
const saltBase64 = btoa(String.fromCharCode(...salt));
|
||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
||||
// 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);
|
||||
|
|
@ -167,12 +153,36 @@ const UserKeysSettings: FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable encryption flow: update the encryption fields to null.
|
||||
*/
|
||||
const disableEncryption = async (): Promise<void> => {
|
||||
try {
|
||||
await setEncryption({
|
||||
encryptionPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptionSalt: null,
|
||||
encryptionIV: null,
|
||||
});
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
// Update local user state with null encryption fields.
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
encryptionPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptionSalt: null,
|
||||
encryptionIV: null,
|
||||
}) as TUser);
|
||||
} catch (error) {
|
||||
console.error('Error disabling encryption:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
// Activate encryption (or re-activate) by generating new keys.
|
||||
const newEncryption = await activateEncryption();
|
||||
if (newEncryption) {
|
||||
try {
|
||||
// Call the mutation to update the backend.
|
||||
// Call the mutation to update the backend with new encryption fields.
|
||||
await setEncryption(newEncryption);
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
// Update local user state with the new encryption keys.
|
||||
|
|
@ -198,10 +208,9 @@ const UserKeysSettings: FC = () => {
|
|||
<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>
|
||||
<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"
|
||||
|
|
@ -211,6 +220,17 @@ const UserKeysSettings: FC = () => {
|
|||
<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>
|
||||
|
||||
{/* Optionally display current public key */}
|
||||
|
|
@ -224,9 +244,7 @@ const UserKeysSettings: FC = () => {
|
|||
<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>
|
||||
<OGDialogTitle>{localize('com_nav_chat_enter_your_passphrase')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -234,6 +234,6 @@ export default function useSSE(
|
|||
sse.dispatchEvent(e);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [submission]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -483,6 +483,9 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -111,10 +111,6 @@ 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 = {
|
||||
|
|
@ -482,10 +478,10 @@ export type TBannerResponse = TBanner | null;
|
|||
* Request type for updating user encryption keys.
|
||||
*/
|
||||
export type UpdateUserEncryptionRequest = {
|
||||
encryptionPublicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
encryptionSalt: string;
|
||||
encryptionIV: string;
|
||||
encryptionPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
encryptionSalt: string | null;
|
||||
encryptionIV: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue