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

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

@ -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,19 +208,29 @@ 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"
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>
<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>
</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

View file

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