mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 02:40:14 +01:00
feat: started with proper E2EE ;)
This commit is contained in:
parent
e3b5c59949
commit
18d019d8b3
13 changed files with 380 additions and 1 deletions
|
|
@ -0,0 +1,249 @@
|
|||
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 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).
|
||||
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, // Adjust as needed for security/performance trade-off
|
||||
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;
|
||||
};
|
||||
|
||||
// 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();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
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' });
|
||||
},
|
||||
});
|
||||
|
||||
// Activation/Update flow: Generate new keys and update user encryption fields.
|
||||
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 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)));
|
||||
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
|
||||
|
||||
// Derive a symmetric key from the passphrase.
|
||||
const derivedKey = await deriveKey(passphrase, salt);
|
||||
|
||||
// Encrypt the private key.
|
||||
const encoder = new TextEncoder();
|
||||
const privateKeyBytes = encoder.encode(privateKeyBase64);
|
||||
const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
derivedKey,
|
||||
privateKeyBytes
|
||||
);
|
||||
const encryptedPrivateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
|
||||
|
||||
// Convert salt and IV to Base64.
|
||||
const saltBase64 = btoa(String.fromCharCode(...salt));
|
||||
const ivBase64 = 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 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.
|
||||
await setEncryption(newEncryption);
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
// Update local user state with the new encryption keys.
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
...newEncryption,
|
||||
}) as TUser);
|
||||
} catch (error) {
|
||||
console.error('Mutation error:', error);
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setPassphrase('');
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setPassphrase(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* List item style */}
|
||||
<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>
|
||||
<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 */}
|
||||
{user?.encryptionPublicKey && (
|
||||
<div className="pt-2 text-xs text-gray-500">
|
||||
{localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog for setting/updating keys */}
|
||||
<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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue