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
+31 -1
View File
@@ -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;
}
}