Add encrypted key support for chat and nodes
Introduces encrypted private key storage for nodes and users, updates chat message schema to support sender-side encryption, and adds supporting libraries and tests for cryptographic signing and identity unlock flows. Includes new database migrations, API route updates, and React components for identity unlock prompts.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages } from '@/db';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
|
||||
@@ -42,32 +42,40 @@ export async function DELETE(
|
||||
if (deleteFor === 'both') {
|
||||
// Delete the entire conversation and all messages (cascade will handle messages)
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||
|
||||
|
||||
// Send deletion request to the other party
|
||||
const participant2Handle = conversation.participant2Handle;
|
||||
const isRemote = participant2Handle.includes('@');
|
||||
|
||||
|
||||
if (isRemote) {
|
||||
// Extract domain from handle (format: handle@domain)
|
||||
const domain = participant2Handle.split('@')[1];
|
||||
const handle = participant2Handle.split('@')[0];
|
||||
|
||||
|
||||
try {
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
|
||||
|
||||
|
||||
// SECURITY: Sign the deletion request
|
||||
const { signPayload, getNodePrivateKey } = await import('@/lib/swarm/signature');
|
||||
const privateKey = await getNodePrivateKey();
|
||||
|
||||
const payload = {
|
||||
senderHandle: session.user.handle,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: handle,
|
||||
conversationId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const signature = signPayload(payload, privateKey);
|
||||
|
||||
await fetch(`${protocol}://${domain}/api/swarm/chat/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
senderHandle: session.user.handle,
|
||||
senderNodeDomain: nodeDomain,
|
||||
recipientHandle: handle,
|
||||
conversationId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
body: JSON.stringify({ ...payload, signature }),
|
||||
});
|
||||
|
||||
|
||||
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
|
||||
} catch (error) {
|
||||
console.error('[Chat Delete] Failed to notify remote node:', error);
|
||||
@@ -76,9 +84,9 @@ export async function DELETE(
|
||||
} else {
|
||||
// Local user - find and delete their conversation too
|
||||
const recipientUser = await db.query.users.findFirst({
|
||||
where: eq(db.users.handle, participant2Handle),
|
||||
where: eq(users.handle, participant2Handle),
|
||||
});
|
||||
|
||||
|
||||
if (recipientUser) {
|
||||
// Find their conversation with us
|
||||
const recipientConversation = await db.query.chatConversations.findFirst({
|
||||
@@ -87,26 +95,26 @@ export async function DELETE(
|
||||
eq(chatConversations.participant2Handle, session.user.handle)
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
if (recipientConversation) {
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
|
||||
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for both parties'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for both parties'
|
||||
});
|
||||
} else {
|
||||
// Delete for self only - just delete the conversation record
|
||||
// The other party will still have their copy
|
||||
await db.delete(chatConversations).where(eq(chatConversations.id, id));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for you'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Conversation deleted for you'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* POST: Receives conversation deletion requests from other swarm nodes
|
||||
*
|
||||
* Security: Only allows deletion if the sender is actually a participant in the conversation
|
||||
* and the request is cryptographically signed.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, chatConversations } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const deletionSchema = z.object({
|
||||
senderHandle: z.string(),
|
||||
@@ -17,6 +19,7 @@ const deletionSchema = z.object({
|
||||
recipientHandle: z.string(),
|
||||
conversationId: z.string().optional(),
|
||||
timestamp: z.string(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -28,6 +31,20 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const data = deletionSchema.parse(body);
|
||||
|
||||
// SECURITY: Verify the signature
|
||||
const { signature, ...payload } = data;
|
||||
const isValid = await verifyUserInteraction(
|
||||
payload,
|
||||
signature,
|
||||
data.senderHandle,
|
||||
data.senderNodeDomain
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`[Swarm Chat Delete] Invalid signature from ${data.senderHandle}@${data.senderNodeDomain}`);
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Find the recipient (local user)
|
||||
const recipient = await db.query.users.findFirst({
|
||||
where: eq(users.handle, data.recipientHandle.toLowerCase()),
|
||||
|
||||
Reference in New Issue
Block a user