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:
+31
-1
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||
import { cookies } from 'next/headers';
|
||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||
|
||||
@@ -149,6 +150,9 @@ export async function registerUser(
|
||||
// Generate cryptographic keys
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt the private key with user's password before storing
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Create the user
|
||||
const did = generateDID();
|
||||
const passwordHash = await hashPassword(password);
|
||||
@@ -160,7 +164,7 @@ export async function registerUser(
|
||||
passwordHash,
|
||||
displayName: displayName || handle,
|
||||
publicKey,
|
||||
privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||
}).returning();
|
||||
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
@@ -195,5 +199,31 @@ export async function authenticateUser(
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Check if private key needs to be encrypted (migration from plaintext)
|
||||
if (user.privateKeyEncrypted && !isEncryptedPrivateKeyStored(user.privateKeyEncrypted)) {
|
||||
// Private key is stored in plaintext - encrypt it now
|
||||
console.log(`[Auth] Encrypting private key for user ${user.handle}`);
|
||||
const encryptedPrivateKey = encryptPrivateKey(user.privateKeyEncrypted, password);
|
||||
await db.update(users)
|
||||
.set({ privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey) })
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if stored private key is encrypted (vs plaintext PEM)
|
||||
*/
|
||||
function isEncryptedPrivateKeyStored(value: string): boolean {
|
||||
if (!value) return false;
|
||||
// Plaintext PEM keys start with -----BEGIN
|
||||
if (value.startsWith('-----BEGIN')) return false;
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed.encrypted && parsed.salt && parsed.iv;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// Storage keys
|
||||
const PRIVATE_KEY_STORAGE = 'synapsis_chat_private_key';
|
||||
const PUBLIC_KEY_STORAGE = 'synapsis_chat_public_key';
|
||||
|
||||
interface ChatKeys {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
interface ServerKeyData {
|
||||
chatPublicKey: string | null;
|
||||
chatPrivateKeyEncrypted: string | null;
|
||||
hasKeys: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing E2E chat encryption
|
||||
* Private keys are encrypted with user's password before server backup
|
||||
*/
|
||||
export function useChatEncryption() {
|
||||
const [keys, setKeys] = useState<ChatKeys | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [needsPasswordToRestore, setNeedsPasswordToRestore] = useState(false);
|
||||
const [serverKeyData, setServerKeyData] = useState<ServerKeyData | null>(null);
|
||||
|
||||
// Check for existing keys on mount
|
||||
useEffect(() => {
|
||||
checkKeys();
|
||||
}, []);
|
||||
|
||||
const checkKeys = async () => {
|
||||
// First check localStorage
|
||||
const publicKey = localStorage.getItem(PUBLIC_KEY_STORAGE);
|
||||
const privateKey = localStorage.getItem(PRIVATE_KEY_STORAGE);
|
||||
|
||||
if (publicKey && privateKey) {
|
||||
setKeys({ publicKey, privateKey });
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server has encrypted backup
|
||||
try {
|
||||
const res = await fetch('/api/chat/keys');
|
||||
if (res.ok) {
|
||||
const data: ServerKeyData = await res.json();
|
||||
setServerKeyData(data);
|
||||
|
||||
if (data.hasKeys && data.chatPrivateKeyEncrypted) {
|
||||
// Keys exist on server but not locally - need password to restore
|
||||
setNeedsPasswordToRestore(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check server keys:', error);
|
||||
}
|
||||
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
// Restore keys from server backup using password
|
||||
const restoreKeysWithPassword = useCallback(async (password: string): Promise<boolean> => {
|
||||
if (!serverKeyData?.chatPrivateKeyEncrypted || !serverKeyData?.chatPublicKey) {
|
||||
throw new Error('No keys to restore');
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt the private key using password
|
||||
const privateKey = await decryptPrivateKeyWithPassword(
|
||||
serverKeyData.chatPrivateKeyEncrypted,
|
||||
password
|
||||
);
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem(PUBLIC_KEY_STORAGE, serverKeyData.chatPublicKey);
|
||||
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
|
||||
|
||||
setKeys({ publicKey: serverKeyData.chatPublicKey, privateKey });
|
||||
setNeedsPasswordToRestore(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to restore keys:', error);
|
||||
return false;
|
||||
}
|
||||
}, [serverKeyData]);
|
||||
|
||||
// Generate new keys and register with server (encrypted backup)
|
||||
const generateAndRegisterKeys = useCallback(async (password: string) => {
|
||||
setIsRegistering(true);
|
||||
try {
|
||||
// Generate ECDH key pair using Web Crypto API
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
const publicKey = bufferToBase64(publicKeyBuffer);
|
||||
const privateKey = bufferToBase64(privateKeyBuffer);
|
||||
|
||||
// Encrypt private key with password for server backup
|
||||
const encryptedPrivateKey = await encryptPrivateKeyWithPassword(privateKey, password);
|
||||
|
||||
// Store private key locally (NEVER sent unencrypted to server)
|
||||
localStorage.setItem(PRIVATE_KEY_STORAGE, privateKey);
|
||||
localStorage.setItem(PUBLIC_KEY_STORAGE, publicKey);
|
||||
|
||||
// Register public key + encrypted private key backup with server
|
||||
const response = await fetch('/api/chat/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chatPublicKey: publicKey,
|
||||
chatPrivateKeyEncrypted: encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to register chat keys');
|
||||
}
|
||||
|
||||
setKeys({ publicKey, privateKey });
|
||||
setNeedsPasswordToRestore(false);
|
||||
return { publicKey, privateKey };
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Encrypt a message for a recipient
|
||||
const encryptMessage = useCallback(async (
|
||||
message: string,
|
||||
recipientPublicKey: string
|
||||
): Promise<string> => {
|
||||
if (!keys?.privateKey) {
|
||||
throw new Error('No chat keys available');
|
||||
}
|
||||
|
||||
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
||||
const theirPublicKey = await importPublicKey(recipientPublicKey);
|
||||
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);
|
||||
}, [keys]);
|
||||
|
||||
// Decrypt a message from a sender
|
||||
const decryptMessage = useCallback(async (
|
||||
encryptedMessage: string,
|
||||
senderPublicKey: string
|
||||
): Promise<string> => {
|
||||
if (!keys?.privateKey) {
|
||||
throw new Error('No chat keys available');
|
||||
}
|
||||
|
||||
const myPrivateKey = await importPrivateKey(keys.privateKey);
|
||||
const theirPublicKey = await importPublicKey(senderPublicKey);
|
||||
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);
|
||||
}, [keys]);
|
||||
|
||||
// Clear keys (on logout)
|
||||
const clearKeys = useCallback(() => {
|
||||
localStorage.removeItem(PRIVATE_KEY_STORAGE);
|
||||
localStorage.removeItem(PUBLIC_KEY_STORAGE);
|
||||
setKeys(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keys,
|
||||
isReady,
|
||||
isRegistering,
|
||||
hasKeys: !!keys,
|
||||
needsPasswordToRestore,
|
||||
generateAndRegisterKeys,
|
||||
restoreKeysWithPassword,
|
||||
encryptMessage,
|
||||
decryptMessage,
|
||||
clearKeys,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Password-based encryption for private key backup
|
||||
// ============================================
|
||||
|
||||
async function encryptPrivateKeyWithPassword(privateKey: string, password: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Derive key from password using PBKDF2
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const aesKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
passwordKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt the private key
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
aesKey,
|
||||
encoder.encode(privateKey)
|
||||
);
|
||||
|
||||
// Return as JSON with all components
|
||||
return JSON.stringify({
|
||||
salt: bufferToBase64(salt.buffer),
|
||||
iv: bufferToBase64(iv.buffer),
|
||||
ciphertext: bufferToBase64(ciphertext),
|
||||
});
|
||||
}
|
||||
|
||||
async function decryptPrivateKeyWithPassword(encryptedData: string, password: string): Promise<string> {
|
||||
const { salt, iv, ciphertext } = JSON.parse(encryptedData);
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Derive key from password
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const aesKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: base64ToBuffer(salt),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
passwordKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64ToBuffer(iv) },
|
||||
aesKey,
|
||||
base64ToBuffer(ciphertext)
|
||||
);
|
||||
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ECDH Key helpers
|
||||
// ============================================
|
||||
|
||||
async function importPublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||
const keyBuffer = base64ToBuffer(publicKeyBase64);
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
keyBuffer,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
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']
|
||||
);
|
||||
}
|
||||
|
||||
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']
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Buffer utilities
|
||||
// ============================================
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user