Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs

Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0
Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908
Hop-Task: T_06FPAWA3279RBMJAR0BHM10
Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
This commit is contained in:
2026-07-15 06:40:06 -07:00
committed by Hop
parent 219a40bea4
commit b46be5c076
54 changed files with 14409 additions and 1192 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ const announcementSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
timestamp: z.string().optional(),
});
@@ -5,10 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { db, chatConversations } from '@/db';
import { eq } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -62,67 +61,31 @@ export async function DELETE(
}
if (deleteFor === 'both') {
const participant2Handle = conversation.participant2Handle;
if (participant2Handle.includes('@')) {
return NextResponse.json({
error: 'Delete for everyone is not supported across nodes. You can still delete this conversation for yourself.',
code: 'REMOTE_DELETE_FOR_EVERYONE_UNSUPPORTED',
}, { status: 409 });
}
// 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('@');
// Local user - find and delete their conversation too.
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
});
if (isRemote) {
// Extract domain from handle (format: handle@domain)
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
if (await isNodeBlocked(domain)) {
return NextResponse.json({ success: true });
}
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({ ...payload, signature }),
});
console.log(`[Chat Delete] Sent deletion request to ${domain}`);
} catch (error) {
console.error('[Chat Delete] Failed to notify remote node:', error);
// Continue anyway - local deletion succeeded
}
} else {
// Local user - find and delete their conversation too
const recipientUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { participant2Handle: session.user.handle }] },
});
if (recipientUser) {
// Find their conversation with us
const recipientConversation = await db.query.chatConversations.findFirst({
where: { AND: [{ participant1Id: recipientUser.id }, { 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}`);
}
if (recipientConversation) {
await db.delete(chatConversations).where(eq(chatConversations.id, recipientConversation.id));
console.log(`[Chat Delete] Deleted conversation for local user ${participant2Handle}`);
}
}
+61 -11
View File
@@ -4,12 +4,13 @@
* GET: List all conversations for the current user
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, isNull, sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db, chatMessages } from '@/db';
import { eq, and, isNull, sql } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
export async function GET(request: NextRequest) {
export async function GET() {
try {
if (!db) {
return NextResponse.json({ conversations: [] });
@@ -87,12 +88,13 @@ export async function GET(request: NextRequest) {
avatarUrl: profileData.profile.avatarUrl || null,
did: profileData.profile.did || '',
isBot: profileData.profile.isBot || false,
publicKey: profileData.profile.publicKey,
});
// Re-query to get the new cached user
cachedUser = await db.query.users.findFirst({
where: { handle: participant2Handle },
}) as any;
});
}
} catch (e) {
console.error(`[Lazy Load] Failed for ${participant2Handle}:`, e);
@@ -102,18 +104,66 @@ export async function GET(request: NextRequest) {
if (cachedUser) {
participant2Info = {
handle: cachedUser.handle,
displayName: (cachedUser as any).displayName || cachedUser.handle,
avatarUrl: (cachedUser as any).avatarUrl || null,
did: (cachedUser as any).did || '',
displayName: cachedUser.displayName || cachedUser.handle,
avatarUrl: cachedUser.avatarUrl || null,
did: cachedUser.did || '',
};
}
const latest = conv.messages[0] || null;
let lastMessage: {
protocolVersion: number;
content: string | null;
encryptedEnvelope: unknown;
signedAction: unknown;
senderPublicKey: string | null;
} | null = latest ? {
protocolVersion: 0,
content: latest.content,
encryptedEnvelope: null,
signedAction: null,
senderPublicKey: null as string | null,
} : null;
if (latest?.protocolVersion === E2EE_PROTOCOL_VERSION && latest.encryptedEnvelope
&& latest.senderDid && latest.e2eeSignature && latest.e2eeActionNonce && latest.e2eeActionTs) {
try {
const encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(latest.encryptedEnvelope));
const senderPublicKey = latest.senderDid === session.user.did
? session.user.publicKey
: cachedUser?.did === latest.senderDid
? cachedUser.publicKey
: null;
lastMessage = {
protocolVersion: E2EE_PROTOCOL_VERSION,
content: null,
encryptedEnvelope,
signedAction: {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: latest.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: latest.e2eeActionTs,
nonce: latest.e2eeActionNonce,
sig: latest.e2eeSignature,
},
senderPublicKey,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid conversation preview ${latest.id}:`, error);
lastMessage = null;
}
}
const { messages: _rawMessages, ...conversation } = conv;
void _rawMessages;
return {
...conv,
...conversation,
participant2: {
...participant2Info,
isBot: (cachedUser as any)?.isBot || false,
isBot: cachedUser?.isBot || false,
},
lastMessage,
unreadCount: Number(unreadCount[0]?.count || 0),
};
})
@@ -121,7 +171,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
conversations: conversationsWithUnread.filter(c => !c.participant2.isBot),
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
console.error('List conversations error:', error);
return NextResponse.json({ error: 'Failed to list conversations' }, { status: 500 });
+36 -7
View File
@@ -6,10 +6,11 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { db, chatMessages, users } from '@/db';
import { eq, and, isNull } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { z } from 'zod';
import { E2EE_CHAT_ACTION, E2EE_PROTOCOL_VERSION, e2eeMessageEnvelopeSchema } from '@/lib/e2ee/protocol';
// Schema for query parameters
const messagesQuerySchema = z.object({
@@ -23,6 +24,8 @@ const markReadSchema = z.object({
conversationId: z.string().uuid(),
});
type ChatUser = typeof users.$inferSelect;
export async function GET(request: NextRequest) {
try {
@@ -83,8 +86,8 @@ export async function GET(request: NextRequest) {
});
// Fetch users
const usersByDid: Record<string, any> = {};
const usersByHandle: Record<string, any> = {};
const usersByDid: Record<string, ChatUser> = {};
const usersByHandle: Record<string, ChatUser> = {};
if (senderDids.size > 0) {
const found = await db.query.users.findMany({
@@ -102,7 +105,7 @@ export async function GET(request: NextRequest) {
}
const messagesMapped = messages.map((msg) => {
const isSentByMe = msg.senderHandle === session.user.handle;
const isSentByMe = msg.senderDid === session.user.did || msg.senderHandle === session.user.handle;
// Resolve fresh user data
const user = msg.senderDid ? usersByDid[msg.senderDid] : usersByHandle[msg.senderHandle];
@@ -110,13 +113,39 @@ export async function GET(request: NextRequest) {
const displayName = user?.displayName || msg.senderDisplayName || msg.senderHandle;
const avatarUrl = user?.avatarUrl || msg.senderAvatarUrl;
let encryptedEnvelope = null;
let signedAction = null;
if (msg.protocolVersion === E2EE_PROTOCOL_VERSION && msg.encryptedEnvelope) {
try {
encryptedEnvelope = e2eeMessageEnvelopeSchema.parse(JSON.parse(msg.encryptedEnvelope));
if (!msg.senderDid || !msg.e2eeSignature || !msg.e2eeActionNonce || !msg.e2eeActionTs) {
throw new Error('Encrypted message signature metadata is incomplete');
}
signedAction = {
action: E2EE_CHAT_ACTION,
data: encryptedEnvelope,
did: msg.senderDid,
handle: encryptedEnvelope.senderHandle,
ts: msg.e2eeActionTs,
nonce: msg.e2eeActionNonce,
sig: msg.e2eeSignature,
};
} catch (error) {
console.error(`[E2EE Chat] Invalid stored envelope ${msg.id}:`, error);
}
}
return {
id: msg.id,
senderHandle: msg.senderHandle,
senderDisplayName: displayName,
senderAvatarUrl: avatarUrl,
senderDid: msg.senderDid,
content: msg.content,
content: msg.protocolVersion === 0 ? msg.content : null,
protocolVersion: msg.protocolVersion,
encryptedEnvelope,
signedAction,
senderPublicKey: user?.publicKey || null,
deliveredAt: msg.deliveredAt,
readAt: msg.readAt,
createdAt: msg.createdAt,
@@ -127,7 +156,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
messages: messagesMapped.reverse(), // Oldest first for display
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
});
}, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+1 -1
View File
@@ -31,7 +31,7 @@ const nodeInfoSchema = z.object({
userCount: z.number().optional(),
postCount: z.number().optional(),
isNsfw: z.boolean().optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions'])).optional(),
capabilities: z.array(z.enum(['handles', 'gossip', 'relay', 'search', 'interactions', 'e2ee_dm_v1'])).optional(),
lastSeenAt: z.string().optional(),
});