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
+78
View File
@@ -0,0 +1,78 @@
/**
* Chat Keys API
*
* GET: Get current user's chat keys (public key + encrypted private key backup)
* POST: Register/update chat keys with encrypted private key backup
*
* The private key is encrypted CLIENT-SIDE with the user's password before
* being sent to the server. The server cannot decrypt it.
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
export async function GET() {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json({
chatPublicKey: user.chatPublicKey,
// Return encrypted private key so client can decrypt with password
chatPrivateKeyEncrypted: user.chatPrivateKeyEncrypted,
hasKeys: !!user.chatPublicKey,
});
} catch (error) {
console.error('Get chat keys error:', error);
return NextResponse.json({ error: 'Failed to get chat keys' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { chatPublicKey, chatPrivateKeyEncrypted } = await request.json();
if (!chatPublicKey || typeof chatPublicKey !== 'string') {
return NextResponse.json({ error: 'chatPublicKey required' }, { status: 400 });
}
// Validate it looks like a base64 SPKI key
if (chatPublicKey.length < 50 || chatPublicKey.length > 500) {
return NextResponse.json({ error: 'Invalid public key format' }, { status: 400 });
}
// chatPrivateKeyEncrypted should be a JSON string with encrypted data
if (chatPrivateKeyEncrypted && typeof chatPrivateKeyEncrypted !== 'string') {
return NextResponse.json({ error: 'Invalid encrypted private key format' }, { status: 400 });
}
await db.update(users)
.set({
chatPublicKey,
chatPrivateKeyEncrypted: chatPrivateKeyEncrypted || null,
})
.where(eq(users.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {
console.error('Register chat keys error:', error);
return NextResponse.json({ error: 'Failed to register chat keys' }, { status: 500 });
}
}
+16 -3
View File
@@ -71,10 +71,23 @@ export async function GET(request: NextRequest) {
const messagesWithDecryption = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
// Return the appropriate encrypted content:
// - For sent messages: use senderEncryptedContent (encrypted with sender's key)
// - For received messages: use encryptedContent (encrypted with recipient's key)
const encryptedForViewer = isSentByMe
? (msg.senderEncryptedContent || msg.encryptedContent)
: msg.encryptedContent;
return {
...msg,
// Only include encrypted content - client will decrypt
content: undefined, // Remove plaintext
id: msg.id,
senderHandle: msg.senderHandle,
senderDisplayName: msg.senderDisplayName,
senderAvatarUrl: msg.senderAvatarUrl,
senderPublicKey: msg.senderChatPublicKey, // For E2E decryption
encryptedContent: encryptedForViewer,
deliveredAt: msg.deliveredAt,
readAt: msg.readAt,
createdAt: msg.createdAt,
isSentByMe,
};
});
+33 -10
View File
@@ -14,7 +14,13 @@ import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types';
const sendMessageSchema = z.object({
recipientHandle: z.string(),
content: z.string().min(1).max(5000),
// For E2E encryption: client sends pre-encrypted content
encryptedContent: z.string().optional(),
senderPublicKey: z.string().optional(), // ECDH public key for decryption
// Legacy: server-side encryption (will be removed)
content: z.string().min(1).max(5000).optional(),
}).refine(data => data.encryptedContent || data.content, {
message: 'Either encryptedContent or content is required',
});
export async function POST(request: NextRequest) {
@@ -152,14 +158,29 @@ export async function POST(request: NextRequest) {
recipientPublicKey = recipientUser.publicKey;
}
// Encrypt the message with recipient's public key
console.log('[Chat Send] Encrypting message...');
// Handle encryption - either client-side (E2E) or server-side (legacy)
let encryptedContent: string;
try {
encryptedContent = encryptMessage(data.content, recipientPublicKey);
} catch (encError) {
console.error('[Chat Send] Encryption failed:', encError);
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
let senderEncryptedContent: string | null = null;
if (data.encryptedContent) {
// E2E mode: client already encrypted the message
// Server cannot read the content - this is true E2E encryption
console.log('[Chat Send] Using client-side E2E encryption');
encryptedContent = data.encryptedContent;
// Store sender's public key so recipient can decrypt
// Note: senderEncryptedContent not needed in E2E mode - client stores locally
} else if (data.content) {
// Legacy mode: server-side encryption (for backwards compatibility)
console.log('[Chat Send] Using server-side encryption (legacy)');
try {
encryptedContent = encryptMessage(data.content, recipientPublicKey);
senderEncryptedContent = encryptMessage(data.content, sender.publicKey);
} catch (encError) {
console.error('[Chat Send] Encryption failed:', encError);
return NextResponse.json({ error: 'Encryption failed' }, { status: 500 });
}
} else {
return NextResponse.json({ error: 'No message content provided' }, { status: 400 });
}
// Get or create conversation
@@ -176,7 +197,7 @@ export async function POST(request: NextRequest) {
participant1Id: sender.id,
participant2Handle: recipientHandle,
lastMessageAt: new Date(),
lastMessagePreview: data.content.substring(0, 100),
lastMessagePreview: '🔒 Encrypted message',
}).returning();
conversation = newConversation;
}
@@ -194,6 +215,8 @@ export async function POST(request: NextRequest) {
senderAvatarUrl: sender.avatarUrl,
senderNodeDomain: null, // Local sender
encryptedContent,
senderEncryptedContent,
senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, // For E2E decryption
swarmMessageId,
deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local
readAt: null,
@@ -203,7 +226,7 @@ export async function POST(request: NextRequest) {
await db.update(chatConversations)
.set({
lastMessageAt: new Date(),
lastMessagePreview: data.content.substring(0, 100),
lastMessagePreview: '🔒 Encrypted message',
updatedAt: new Date(),
})
.where(eq(chatConversations.id, conversation.id));
+2 -1
View File
@@ -94,7 +94,8 @@ export async function GET(request: Request, context: RouteContext) {
website: user.website,
movedTo: user.movedTo,
isBot: user.isBot,
publicKey: user.publicKey, // Needed for E2E encrypted chat
publicKey: user.publicKey, // RSA key for signing
chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat
};
// If this is a bot, include owner info