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:
Ruben Talstra 2025-02-15 23:04:26 +01:00
parent 18d019d8b3
commit 94d32906f1
No known key found for this signature in database
GPG key ID: 2A5A7174A60F3BEA
11 changed files with 343 additions and 189 deletions

View file

@ -2,6 +2,7 @@ const { z } = require('zod');
const Message = require('./schema/messageSchema'); const Message = require('./schema/messageSchema');
const { logger } = require('~/config'); const { logger } = require('~/config');
// Validate conversation ID as a UUID (if your conversation IDs follow UUID format)
const idSchema = z.string().uuid(); 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.plugin] - Plugin associated with the message.
* @param {string[]} [params.plugins] - An array of plugins 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 {string} [params.model] - The model used to generate the message.
* @param {Object} [metadata] - Additional metadata for this operation * @param {string} [params.iv] - (Optional) Base64-encoded initialization vector for encryption.
* @param {string} [metadata.context] - The context of the operation * @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. * @returns {Promise<TMessage>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message. * @throws {Error} If there is an error in saving the message.
*/ */
@ -51,6 +55,9 @@ async function saveMessage(req, params, metadata) {
...params, ...params,
user: req.user.id, user: req.user.id,
messageId: params.newMessageId || params.messageId, messageId: params.newMessageId || params.messageId,
iv: params.iv ?? null,
authTag: params.authTag ?? null,
encryptedKey: params.encryptedKey ?? null,
}; };
if (req?.body?.isTemporary) { if (req?.body?.isTemporary) {
@ -90,7 +97,12 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
const bulkOps = messages.map((message) => ({ const bulkOps = messages.map((message) => ({
updateOne: { updateOne: {
filter: { messageId: message.messageId }, filter: { messageId: message.messageId },
update: message, update: {
...message,
iv: message.iv ?? null,
authTag: message.authTag ?? null,
encryptedKey: message.encryptedKey ?? null,
},
timestamps: !overrideTimestamp, timestamps: !overrideTimestamp,
upsert: true, upsert: true,
}, },
@ -119,14 +131,7 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
* @returns {Promise<Object>} The updated or newly inserted message document. * @returns {Promise<Object>} The updated or newly inserted message document.
* @throws {Error} If there is an error in saving the message. * @throws {Error} If there is an error in saving the message.
*/ */
async function recordMessage({ async function recordMessage({ user, endpoint, messageId, conversationId, parentMessageId, ...rest }) {
user,
endpoint,
messageId,
conversationId,
parentMessageId,
...rest
}) {
try { try {
// No parsing of convoId as may use threadId // No parsing of convoId as may use threadId
const message = { const message = {
@ -136,6 +141,9 @@ async function recordMessage({
conversationId, conversationId,
parentMessageId, parentMessageId,
...rest, ...rest,
iv: rest.iv ?? null,
authTag: rest.authTag ?? null,
encryptedKey: rest.encryptedKey ?? null,
}; };
return await Message.findOneAndUpdate({ user, messageId }, message, { return await Message.findOneAndUpdate({ user, messageId }, message, {
@ -190,12 +198,15 @@ async function updateMessageText(req, { messageId, text }) {
async function updateMessage(req, message, metadata) { async function updateMessage(req, message, metadata) {
try { try {
const { messageId, ...update } = message; 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( const updatedMessage = await Message.findOneAndUpdate(
{ messageId, user: req.user.id }, { messageId, user: req.user.id },
update, update,
{ { new: true },
new: true,
},
); );
if (!updatedMessage) { if (!updatedMessage) {
@ -225,11 +236,11 @@ async function updateMessage(req, message, metadata) {
* *
* @async * @async
* @function deleteMessagesSince * @function deleteMessagesSince
* @param {Object} params - The parameters object.
* @param {Object} req - The request 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.messageId - The unique identifier for the message.
* @param {string} params.conversationId - The identifier of the conversation. * @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. * @throws {Error} If there is an error in deleting messages.
*/ */
async function deleteMessagesSince(req, { messageId, conversationId }) { async function deleteMessagesSince(req, { messageId, conversationId }) {
@ -263,7 +274,6 @@ async function getMessages(filter, select) {
if (select) { if (select) {
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean(); return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
} }
return await Message.find(filter).sort({ createdAt: 1 }).lean(); return await Message.find(filter).sort({ createdAt: 1 }).lean();
} catch (err) { } catch (err) {
logger.error('Error getting messages:', err); logger.error('Error getting messages:', err);
@ -281,10 +291,7 @@ async function getMessages(filter, select) {
*/ */
async function getMessage({ user, messageId }) { async function getMessage({ user, messageId }) {
try { try {
return await Message.findOne({ return await Message.findOne({ user, messageId }).lean();
user,
messageId,
}).lean();
} catch (err) { } catch (err) {
logger.error('Error getting message:', err); logger.error('Error getting message:', err);
throw err; throw err;

View file

@ -54,11 +54,6 @@ const messageSchema = mongoose.Schema(
type: String, type: String,
meiliIndex: true, 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: { summary: {
type: String, type: String,
}, },
@ -142,6 +137,18 @@ const messageSchema = mongoose.Schema(
expiredAt: { expiredAt: {
type: Date, type: Date,
}, },
iv: {
type: String,
default: null,
},
authTag: {
type: String,
default: null,
},
encryptedKey: {
type: String,
default: null,
},
}, },
{ timestamps: true }, { timestamps: true },
); );

View file

@ -23,13 +23,6 @@ const shareSchema = mongoose.Schema(
type: Boolean, type: Boolean,
default: true, default: true,
}, },
// --- Field for re-encrypting the conversation key for the forked user ---
encryptionKeys: [
{
user: { type: String, index: true },
encryptedKey: { type: String },
},
],
}, },
{ timestamps: true }, { timestamps: true },
); );

View file

@ -27,10 +27,10 @@ const { SystemRoles } = require('librechat-data-provider');
* @property {Array} [plugins=[]] - List of plugins used by the user * @property {Array} [plugins=[]] - List of plugins used by the user
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens * @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
* @property {Date} [expiresAt] - Optional expiration date of the file * @property {Date} [expiresAt] - Optional expiration date of the file
* @property {string} [encryptionPublicKey] - The user's public key for E2EE (client-generated) * @property {string} [encryptionPublicKey] - The user's encryption public key
* @property {string} [encryptedPrivateKey] - The user's private key encrypted with a user-defined passphrase * @property {string} [encryptedPrivateKey] - The user's encrypted private key
* @property {string} [encryptionSalt] - The salt used for PBKDF2 during encryption * @property {string} [encryptionSalt] - The salt used for key derivation (e.g., PBKDF2)
* @property {string} [encryptionIV] - The initialization vector used for encryption (AES-GCM) * @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} [createdAt] - Date when the user was created (added by timestamps)
* @property {Date} [updatedAt] - Date when the user was last updated (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, type: Boolean,
default: false, default: false,
}, },
// --- New Fields for E2EE ---
encryptionPublicKey: { encryptionPublicKey: {
type: String, type: String,
required: false, default: null,
// Provided by the client after key generation.
}, },
encryptedPrivateKey: { encryptedPrivateKey: {
type: String, type: String,
required: false, default: null,
// The private key encrypted on the client with the users encryption passphrase.
}, },
encryptionSalt: { encryptionSalt: {
type: String, type: String,
required: false, default: null,
// Salt used for PBKDF2 when encrypting the private key.
}, },
encryptionIV: { encryptionIV: {
type: String, type: String,
required: false, default: null,
// IV used for AES-GCM encryption of the private key.
}, },
}, },

View file

@ -4,6 +4,13 @@ const { sendMessage, createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models'); const { saveMessage } = require('~/models');
const { logger } = require('~/config'); 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) => { const AskController = async (req, res, next, initializeClient, addTitle) => {
let { let {
text, text,
@ -74,14 +81,8 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
res.on('close', () => { res.on('close', () => {
logger.debug('[AskController] Request closed'); logger.debug('[AskController] Request closed');
if (!abortController) { if (!abortController) {return;}
return; if (abortController.signal.aborted || abortController.requestCompleted) {return;}
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort(); abortController.abort();
logger.debug('[AskController] Request aborted on close'); logger.debug('[AskController] Request aborted on close');
}); });
@ -95,10 +96,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
onStart, onStart,
abortController, abortController,
progressCallback, progressCallback,
progressOptions: { progressOptions: { res },
res,
// parentMessageId: overrideParentMessageId || userMessageId,
},
}; };
/** @type {TMessage} */ /** @type {TMessage} */
@ -115,6 +113,58 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
delete userMessage.image_urls; 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) { if (!abortController.signal.aborted) {
sendMessage(res, { sendMessage(res, {
final: true, final: true,

View file

@ -167,17 +167,20 @@ const updateUserEncryptionController = async (req, res) => {
try { try {
const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body; const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body;
// Validate required parameters // Allow disabling encryption by passing null for all fields.
if (!encryptionPublicKey || !encryptedPrivateKey || !encryptionSalt || !encryptionIV) { 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.' }); 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, { const updatedUser = await updateUser(req.user.id, {
encryptionPublicKey, encryptionPublicKey: encryptionPublicKey || null,
encryptedPrivateKey, encryptedPrivateKey: encryptedPrivateKey || null,
encryptionSalt, encryptionSalt: encryptionSalt || null,
encryptionIV, encryptionIV: encryptionIV || null,
}); });
if (!updatedUser) { if (!updatedUser) {

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 { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common'; import type { TMessageContentProps, TDisplayProps } from '~/common';
@ -13,6 +13,77 @@ import Container from './Container';
import Markdown from './Markdown'; import Markdown from './Markdown';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; 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 = ({ export const ErrorMessage = ({
text, text,
@ -40,12 +111,7 @@ export const ErrorMessage = ({
> >
<DelayedRender delay={5500}> <DelayedRender delay={5500}>
<Container message={message}> <Container message={message}>
<div <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)}>
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')} {localize('com_ui_error_connection')}
</div> </div>
</Container> </Container>
@ -58,10 +124,7 @@ export const ErrorMessage = ({
<div <div
role="alert" role="alert"
aria-live="assertive" aria-live="assertive"
className={cn( 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)}
'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} /> <Error text={text} />
</div> </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 { isSubmitting, latestMessage } = useChatContext();
const { user } = useAuthContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo( const showCursorState = useMemo(() => showCursor === true && isSubmitting, [showCursor, isSubmitting]);
() => showCursor === true && isSubmitting, const isLatestMessage = useMemo(() => message.messageId === latestMessage?.messageId, [message.messageId, latestMessage?.messageId]);
[showCursor, isSubmitting],
); // State to hold the final text to display (decrypted if needed)
const isLatestMessage = useMemo( const [displayText, setDisplayText] = useState<string>(text);
() => message.messageId === latestMessage?.messageId, const [decryptionError, setDecryptionError] = useState<string | null>(null);
[message.messageId, latestMessage?.messageId],
); 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; let content: React.ReactElement;
if (!isCreatedByUser) { if (!isCreatedByUser) {
content = ( content = <Markdown content={displayText} showCursor={showCursorState} isLatestMessage={isLatestMessage} />;
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
);
} else if (enableUserMsgMarkdown) { } else if (enableUserMsgMarkdown) {
content = <MarkdownLite content={text} />; content = <MarkdownLite content={displayText} />;
} else { } else {
content = <>{text}</>; content = <>{displayText}</>;
} }
return ( return (
<Container message={message}> <Container message={message}>
<div <div className={cn(
className={cn(
isSubmitting ? 'submitting' : '', isSubmitting ? 'submitting' : '',
showCursorState && !!text.length ? 'result-streaming' : '', showCursorState && !!displayText.length ? 'result-streaming' : '',
'markdown prose message-content dark:prose-invert light w-full break-words', 'markdown prose message-content dark:prose-invert light w-full break-words',
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap', isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100', isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
)} className
> )}>
{content} {decryptionError ? <span className="text-red-500">{decryptionError}</span> : content}
</div> </div>
</Container> </Container>
); );
@ -162,12 +249,7 @@ const MessageContent = ({
{thinkingContent.length > 0 && ( {thinkingContent.length > 0 && (
<Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking> <Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking>
)} )}
<DisplayMessage <DisplayMessage key={`display-${messageId}`} showCursor={showRegularCursor} text={regularContent} {...props} />
key={`display-${messageId}`}
showCursor={showRegularCursor}
text={regularContent}
{...props}
/>
{unfinishedMessage} {unfinishedMessage}
</> </>
); );

View file

@ -15,24 +15,17 @@ import type { TUser } from 'librechat-data-provider';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { useSetUserEncryptionMutation } from '~/data-provider'; import { useSetUserEncryptionMutation } from '~/data-provider';
// Helper: Convert a Base64 string to Uint8Array. /**
const base64ToUint8Array = (base64: string): Uint8Array => { * Helper: Convert a Uint8Array to a hex string (for debugging).
const binaryString = window.atob(base64); */
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// Helper: Convert a Uint8Array to a hex string (for debugging).
const uint8ArrayToHex = (array: Uint8Array): string => const uint8ArrayToHex = (array: Uint8Array): string =>
Array.from(array) Array.from(array)
.map(b => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .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 deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKey> => {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey( const keyMaterial = await window.crypto.subtle.importKey(
@ -46,7 +39,7 @@ const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKe
{ {
name: 'PBKDF2', name: 'PBKDF2',
salt, salt,
iterations: 100000, // Adjust as needed for security/performance trade-off iterations: 100000,
hash: 'SHA-256', hash: 'SHA-256',
}, },
keyMaterial, keyMaterial,
@ -60,22 +53,6 @@ const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKe
return derivedKey; 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 UserKeysSettings: FC = () => {
const localize = useLocalize(); const localize = useLocalize();
const { user } = useAuthContext(); 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<{ const activateEncryption = async (): Promise<{
encryptionPublicKey: string; encryptionPublicKey: string;
encryptedPrivateKey: string; encryptedPrivateKey: string;
@ -122,22 +107,23 @@ const UserKeysSettings: FC = () => {
true, true,
['encrypt', 'decrypt'] ['encrypt', 'decrypt']
); );
// Export public and private keys.
// Export the public and private keys.
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey); const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey); const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer))); const publicKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer)));
const privateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer))); const privateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer)));
console.debug('New public key:', publicKeyBase64); console.debug('New public key:', publicKeyBase64);
console.debug('New private key (plaintext):', privateKeyBase64); console.debug('New private key (plaintext):', privateKeyBase64);
// Generate a salt and IV. // Generate a salt (16 bytes) and IV (12 bytes) for AES-GCM.
const salt = window.crypto.getRandomValues(new Uint8Array(16)); // 16 bytes salt const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 12 bytes IV 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); const derivedKey = await deriveKey(passphrase, salt);
// Encrypt the private key. // Encrypt the private key using AES-GCM.
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const privateKeyBytes = encoder.encode(privateKeyBase64); const privateKeyBytes = encoder.encode(privateKeyBase64);
const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt( const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt(
@ -145,11 +131,11 @@ const UserKeysSettings: FC = () => {
derivedKey, derivedKey,
privateKeyBytes privateKeyBytes
); );
const encryptedPrivateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer))); const encryptedPrivateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
// Convert salt and IV to Base64. // Convert salt and IV to Base64 strings.
const saltBase64 = btoa(String.fromCharCode(...salt)); const saltBase64 = window.btoa(String.fromCharCode(...salt));
const ivBase64 = btoa(String.fromCharCode(...iv)); const ivBase64 = window.btoa(String.fromCharCode(...iv));
console.debug('Activation complete:'); console.debug('Activation complete:');
console.debug('Encrypted private key:', encryptedPrivateKeyBase64); 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> => { const handleSubmit = async (): Promise<void> => {
// Activate encryption (or re-activate) by generating new keys.
const newEncryption = await activateEncryption(); const newEncryption = await activateEncryption();
if (newEncryption) { if (newEncryption) {
try { try {
// Call the mutation to update the backend. // Call the mutation to update the backend with new encryption fields.
await setEncryption(newEncryption); await setEncryption(newEncryption);
showToast({ message: localize('com_ui_upload_success') }); showToast({ message: localize('com_ui_upload_success') });
// Update local user state with the new encryption keys. // 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 justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Key className="flex w-[20px] h-[20px]" /> <Key className="flex w-[20px] h-[20px]" />
<span id="user-keys-label"> <span id="user-keys-label">{localize('com_nav_chat_encryption_settings')}</span>
{localize('com_nav_chat_encryption_settings')}
</span>
</div> </div>
<div className="flex space-x-2">
<Button <Button
variant="outline" variant="outline"
aria-label="Set/Change encryption keys" aria-label="Set/Change encryption keys"
@ -211,6 +220,17 @@ const UserKeysSettings: FC = () => {
<Lock className="mr-2 flex w-[22px] items-center stroke-1" /> <Lock className="mr-2 flex w-[22px] items-center stroke-1" />
<span>{localize('com_nav_chat_change_passphrase')}</span> <span>{localize('com_nav_chat_change_passphrase')}</span>
</Button> </Button>
{user?.encryptionPublicKey && (
<Button
variant="outline"
aria-label="Disable encryption"
onClick={disableEncryption}
data-testid="disableEncryption"
>
<span>Disable Encryption</span>
</Button>
)}
</div>
</div> </div>
{/* Optionally display current public key */} {/* Optionally display current public key */}
@ -224,9 +244,7 @@ const UserKeysSettings: FC = () => {
<OGDialog open={dialogOpen} onOpenChange={setDialogOpen}> <OGDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}> <OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
<OGDialogHeader> <OGDialogHeader>
<OGDialogTitle> <OGDialogTitle>{localize('com_nav_chat_enter_your_passphrase')}</OGDialogTitle>
{localize('com_nav_chat_enter_your_passphrase')}
</OGDialogTitle>
</OGDialogHeader> </OGDialogHeader>
<div className="p-4 flex flex-col gap-4"> <div className="p-4 flex flex-col gap-4">
<Input <Input

View file

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

View file

@ -483,6 +483,9 @@ export const tMessageSchema = z.object({
thread_id: z.string().optional(), thread_id: z.string().optional(),
/* frontend components */ /* frontend components */
iconURL: z.string().nullable().optional(), 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 TAttachmentMetadata = { messageId: string; toolCallId: string };
@ -490,7 +493,7 @@ export type TAttachment =
| (TFile & TAttachmentMetadata) | (TFile & TAttachmentMetadata)
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & { | (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
expiresAt: number; expiresAt: number;
} & TAttachmentMetadata); } & TAttachmentMetadata);
export type TMessage = z.input<typeof tMessageSchema> & { export type TMessage = z.input<typeof tMessageSchema> & {
children?: TMessage[]; children?: TMessage[];

View file

@ -45,7 +45,7 @@ export type TPayload = Partial<TMessage> &
conversationId: string | null; conversationId: string | null;
messages?: TMessages; messages?: TMessages;
isTemporary: boolean; isTemporary: boolean;
}; };
export type TSubmission = { export type TSubmission = {
artifacts?: string; artifacts?: string;
@ -111,10 +111,6 @@ export type TUser = {
plugins?: string[]; plugins?: string[];
createdAt: string; createdAt: string;
updatedAt: 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 = { export type TGetConversationsResponse = {
@ -482,10 +478,10 @@ export type TBannerResponse = TBanner | null;
* Request type for updating user encryption keys. * Request type for updating user encryption keys.
*/ */
export type UpdateUserEncryptionRequest = { export type UpdateUserEncryptionRequest = {
encryptionPublicKey: string; encryptionPublicKey: string | null;
encryptedPrivateKey: string; encryptedPrivateKey: string | null;
encryptionSalt: string; encryptionSalt: string | null;
encryptionIV: string; encryptionIV: string | null;
}; };
/** /**