feat(chat): Implement client-side end-to-end encryption with key management

- Add chat keys API endpoint for storing and retrieving encrypted RSA public/private key pairs
- Create client-side crypto utilities for E2E encryption/decryption with hybrid encryption support
- Implement useChatEncryption hook for managing encryption state and key generation in chat UI
- Add chatPublicKey and chatPrivateKeyEncrypted fields to user schema for key storage
- Update chat messages API to return encrypted content with sender's public key for decryption
- Modify send message endpoint to accept pre-encrypted content from client while maintaining legacy server-side encryption
- Update chat page UI to integrate client-side encryption workflow
- Ensure private keys are encrypted client-side with user password before transmission to server
- Server cannot decrypt message content in E2E mode, providing true end-to-end encryption
This commit is contained in:
Christomatt
2026-01-27 03:38:54 +01:00
parent fb2c80b0e3
commit f0253581d2
11 changed files with 969 additions and 306 deletions
+185
View File
@@ -0,0 +1,185 @@
/**
* Client-Side E2E Encryption using Web Crypto API
*
* This runs in the browser. Private keys NEVER leave the client.
* Uses ECDH for key exchange and AES-GCM for encryption.
*/
// Storage keys
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
/**
* Generate a new ECDH key pair for chat
*/
export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
const keyPair = await window.crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256',
},
true, // extractable
['deriveKey']
);
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
return {
publicKey: bufferToBase64(publicKeyBuffer),
privateKey: bufferToBase64(privateKeyBuffer),
};
}
/**
* Store keys in localStorage (encrypted with a passphrase in production)
*/
export function storeKeys(publicKey: string, privateKey: string): void {
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
}
/**
* Get stored keys
*/
export function getStoredKeys(): { publicKey: string | null; privateKey: string | null } {
return {
publicKey: localStorage.getItem(PUBLIC_KEY_STORAGE),
privateKey: localStorage.getItem(PRIVATE_KEY_STORAGE),
};
}
/**
* Check if chat keys exist
*/
export function hasChatKeys(): boolean {
const keys = getStoredKeys();
return !!(keys.publicKey && keys.privateKey);
}
/**
* Clear stored keys (logout)
*/
export function clearKeys(): void {
localStorage.removeItem(PUBLIC_KEY_STORAGE);
localStorage.removeItem(PRIVATE_KEY_STORAGE);
}
/**
* Import a public key from base64
*/
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
const keyBuffer = base64ToBuffer(publicKeyBase64);
return window.crypto.subtle.importKey(
'spki',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
}
/**
* Import a private key from base64
*/
async function importPrivateKey(privateKeyBase64: string): Promise<CryptoKey> {
const keyBuffer = base64ToBuffer(privateKeyBase64);
return window.crypto.subtle.importKey(
'pkcs8',
keyBuffer,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
['deriveKey']
);
}
/**
* Derive a shared AES key from ECDH
*/
async function deriveSharedKey(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<CryptoKey> {
return window.crypto.subtle.deriveKey(
{ name: 'ECDH', public: theirPublicKey },
myPrivateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* Encrypt a message for a recipient
*/
export async function encryptMessage(
message: string,
myPrivateKeyBase64: string,
theirPublicKeyBase64: string
): Promise<string> {
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
const encoder = new TextEncoder();
const messageBytes = encoder.encode(message);
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
sharedKey,
messageBytes
);
// Combine iv + ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return bufferToBase64(combined.buffer);
}
/**
* Decrypt a message from a sender
*/
export async function decryptMessage(
encryptedMessage: string,
myPrivateKeyBase64: string,
theirPublicKeyBase64: string
): Promise<string> {
const myPrivateKey = await importPrivateKey(myPrivateKeyBase64);
const theirPublicKey = await importPublicKey(theirPublicKeyBase64);
const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey);
const combined = base64ToBuffer(encryptedMessage);
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
sharedKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
// Utility functions
function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* End-to-End Encrypted Chat Cryptography
*
* Uses ECDH (Elliptic Curve Diffie-Hellman) for key exchange
* and AES-GCM for message encryption.
*
* This is a simplified version of the Signal Protocol approach.
* Private keys NEVER leave the client.
*/
import * as crypto from 'crypto';
/**
* Generate an ECDH key pair for chat encryption
* The private key should be stored client-side only
*/
export function generateChatKeyPair(): { publicKey: string; privateKey: string } {
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
return {
publicKey: ecdh.getPublicKey('base64'),
privateKey: ecdh.getPrivateKey('base64'),
};
}
/**
* Derive a shared secret from your private key and their public key
* This is the magic of ECDH - both parties derive the same secret
*/
export function deriveSharedSecret(myPrivateKey: string, theirPublicKey: string): Buffer {
const ecdh = crypto.createECDH('prime256v1');
ecdh.setPrivateKey(Buffer.from(myPrivateKey, 'base64'));
const sharedSecret = ecdh.computeSecret(Buffer.from(theirPublicKey, 'base64'));
// Derive a proper AES key from the shared secret using HKDF
return crypto.createHash('sha256').update(sharedSecret).digest();
}
/**
* Encrypt a message using the shared secret
*/
export function encryptWithSharedSecret(message: string, sharedSecret: Buffer): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
const encrypted = Buffer.concat([
cipher.update(message, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag();
// Combine: iv (12) + authTag (16) + ciphertext
const combined = Buffer.concat([iv, authTag, encrypted]);
return combined.toString('base64');
}
/**
* Decrypt a message using the shared secret
*/
export function decryptWithSharedSecret(encryptedMessage: string, sharedSecret: Buffer): string {
const combined = Buffer.from(encryptedMessage, 'base64');
const iv = combined.subarray(0, 12);
const authTag = combined.subarray(12, 28);
const ciphertext = combined.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return decrypted.toString('utf8');
}
/**
* High-level: Encrypt a message for a recipient
* Uses sender's private key + recipient's public key
*/
export function encryptMessage(
message: string,
senderPrivateKey: string,
recipientPublicKey: string
): string {
const sharedSecret = deriveSharedSecret(senderPrivateKey, recipientPublicKey);
return encryptWithSharedSecret(message, sharedSecret);
}
/**
* High-level: Decrypt a message from a sender
* Uses recipient's private key + sender's public key
*/
export function decryptMessage(
encryptedMessage: string,
recipientPrivateKey: string,
senderPublicKey: string
): string {
const sharedSecret = deriveSharedSecret(recipientPrivateKey, senderPublicKey);
return decryptWithSharedSecret(encryptedMessage, sharedSecret);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Private Key Encryption/Decryption
*
* Encrypts user private keys with their password using AES-256-GCM.
* This ensures server admins cannot read private keys without the user's password.
*/
import * as crypto from 'crypto';
export interface EncryptedPrivateKey {
encrypted: string; // Base64 encoded ciphertext + auth tag
salt: string; // Base64 encoded salt for PBKDF2
iv: string; // Base64 encoded initialization vector
}
/**
* Encrypt a private key with the user's password
*/
export function encryptPrivateKey(privateKey: string, password: string): EncryptedPrivateKey {
const salt = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// Derive key from password using PBKDF2
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Encrypt with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Combine encrypted data with auth tag
const combined = Buffer.concat([Buffer.from(encrypted, 'base64'), authTag]).toString('base64');
return {
encrypted: combined,
salt: salt.toString('base64'),
iv: iv.toString('base64'),
};
}
/**
* Decrypt a private key with the user's password
*/
export function decryptPrivateKey(encryptedData: EncryptedPrivateKey, password: string): string {
const salt = Buffer.from(encryptedData.salt, 'base64');
const iv = Buffer.from(encryptedData.iv, 'base64');
const combined = Buffer.from(encryptedData.encrypted, 'base64');
// Derive key from password
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// Split encrypted data and auth tag (auth tag is last 16 bytes)
const authTag = combined.subarray(combined.length - 16);
const encryptedContent = combined.subarray(0, combined.length - 16);
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedContent);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Serialize encrypted private key for database storage
*/
export function serializeEncryptedKey(data: EncryptedPrivateKey): string {
return JSON.stringify(data);
}
/**
* Deserialize encrypted private key from database
*/
export function deserializeEncryptedKey(serialized: string): EncryptedPrivateKey {
return JSON.parse(serialized);
}
/**
* Check if a stored value is an encrypted private key (vs plaintext)
*/
export function isEncryptedPrivateKey(value: string): boolean {
if (!value) return false;
try {
const parsed = JSON.parse(value);
return parsed.encrypted && parsed.salt && parsed.iv;
} catch {
return false;
}
}