diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 97ac54a..ec28a32 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -10,6 +10,7 @@ const updateProfileSchema = z.object({ avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(), headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(), website: z.string().url().or(z.string().length(0)).optional().nullable(), + dmPrivacy: z.enum(['everyone', 'following', 'none']).optional(), }); export async function GET() { @@ -33,6 +34,7 @@ export async function GET() { avatarUrl: session.user.avatarUrl, bio: session.user.bio, website: session.user.website, + dmPrivacy: session.user.dmPrivacy, did: session.user.did, publicKey: session.user.publicKey, privateKeyEncrypted: session.user.privateKeyEncrypted, @@ -60,6 +62,7 @@ export async function PATCH(request: Request) { avatarUrl?: string | null; headerUrl?: string | null; website?: string | null; + dmPrivacy?: 'everyone' | 'following' | 'none'; updatedAt?: Date; } = {}; @@ -68,6 +71,7 @@ export async function PATCH(request: Request) { if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl; if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl; if (data.website !== undefined) updateData.website = data.website === '' ? null : data.website; + if (data.dmPrivacy !== undefined) updateData.dmPrivacy = data.dmPrivacy; if (Object.keys(updateData).length === 0) { return NextResponse.json({ @@ -79,6 +83,7 @@ export async function PATCH(request: Request) { bio: currentUser.bio, headerUrl: currentUser.headerUrl, website: currentUser.website, + dmPrivacy: currentUser.dmPrivacy, followersCount: currentUser.followersCount, followingCount: currentUser.followingCount, postsCount: currentUser.postsCount, @@ -103,6 +108,7 @@ export async function PATCH(request: Request) { bio: updatedUser.bio, headerUrl: updatedUser.headerUrl, website: updatedUser.website, + dmPrivacy: updatedUser.dmPrivacy, followersCount: updatedUser.followersCount, followingCount: updatedUser.followingCount, postsCount: updatedUser.postsCount, diff --git a/src/app/api/chat/inbox/route.ts b/src/app/api/chat/inbox/route.ts deleted file mode 100644 index 4044f11..0000000 --- a/src/app/api/chat/inbox/route.ts +++ /dev/null @@ -1,60 +0,0 @@ - -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatInbox } from '@/db/schema'; -import { eq, and, or, isNull } from 'drizzle-orm'; -import { getSession } from '@/lib/auth'; - -/** - * GET /api/chat/inbox - * Poll for new encrypted envelopes for this device. - */ -export async function GET(request: NextRequest) { - try { - const session = await getSession(); - if (!session?.user?.did) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { did } = session.user; - const { searchParams } = new URL(request.url); - const deviceId = searchParams.get('deviceId'); - - if (!deviceId) { - return NextResponse.json({ error: 'Device ID required' }, { status: 400 }); - } - - // Fetch messages for this user AND (this device OR all devices) - const messages = await db.select().from(chatInbox).where( - and( - eq(chatInbox.recipientDid, did), - or( - eq(chatInbox.recipientDeviceId, deviceId), - isNull(chatInbox.recipientDeviceId) // Broadcasts (if any) - ), - eq(chatInbox.isRead, false) - ) - ); - - // TODO: Mark them as read immediately? - // Or client must ACK? - // For now, we return them. Client deals with idempotency. - // If we mark read, checking on another tab might miss them if race condition. - // But uniqueness is (id). - - return NextResponse.json({ messages }); - - } catch (error: any) { - console.error('Inbox poll fail:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} - -/** - * DELETE /api/chat/inbox - * Acknowledge/Delete processed messages - */ -export async function DELETE(request: NextRequest) { - // ... Implement ACK cleaning - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/chat/keys/fetch/route.ts b/src/app/api/chat/keys/fetch/route.ts deleted file mode 100644 index 5e3071d..0000000 --- a/src/app/api/chat/keys/fetch/route.ts +++ /dev/null @@ -1,70 +0,0 @@ - -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const did = searchParams.get('did'); - const nodeDomain = searchParams.get('nodeDomain'); - - if (!did) { - return NextResponse.json({ error: 'Missing DID' }, { status: 400 }); - } - - const handle = searchParams.get('handle'); - - // Helper to fetch and check with timeout - const tryFetch = async (url: string) => { - console.log(`[Proxy] Fetching keys from: ${url}`); - try { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), 5000); // 5s timeout - const res = await fetch(url, { - headers: { 'Accept': 'application/json' }, - signal: controller.signal - }); - clearTimeout(id); - - if (res.ok) return await res.json(); - const text = await res.text(); - console.warn(`[Proxy] Fetch failed for ${url} (${res.status}): ${text}`); - return null; - } catch (err: any) { - console.warn(`[Proxy] Fetch error for ${url}: ${err.name} - ${err.message}`); - return null; - } - }; - - // 1. Try Primary DID - let primaryUrl = ''; - // If did:web, extracting domain is easy - if (did.startsWith('did:web:')) { - const parts = did.split(':'); - if (parts.length >= 4) { - const domain = parts[2]; - const protocol = domain.includes('localhost') ? 'http' : 'https'; - primaryUrl = `${protocol}://${domain}/.well-known/synapsis/chat/${did}`; - } - } else if (nodeDomain) { - const protocol = nodeDomain.includes('localhost') ? 'http' : 'https'; - primaryUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${did}`; - } - - if (primaryUrl) { - const data = await tryFetch(primaryUrl); - if (data) return NextResponse.json(data); - } - - // 2. Try Fallback: did:web (if handle and domain provided) - // The remote user might be indexed by did:web even if we know them as did:synapsis - if (nodeDomain && handle) { - const didWeb = `did:web:${nodeDomain}:${handle}`; - const protocol = nodeDomain.includes('localhost') ? 'http' : 'https'; - const fallbackUrl = `${protocol}://${nodeDomain}/.well-known/synapsis/chat/${didWeb}`; - - console.log(`[Proxy] Primary failed. Trying fallback: ${didWeb}`); - const data = await tryFetch(fallbackUrl); - if (data) return NextResponse.json(data); - } - - return NextResponse.json({ error: 'Remote keys not found (checked primary and fallback)' }, { status: 404 }); -} diff --git a/src/app/api/chat/keys/route.ts b/src/app/api/chat/keys/route.ts deleted file mode 100644 index 87c6c69..0000000 --- a/src/app/api/chat/keys/route.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatDeviceBundles, handleRegistry, remoteIdentityCache, users } from '@/db/schema'; -import { requireAuth } from '@/lib/auth'; -import { eq, and, gt } from 'drizzle-orm'; - -/** - * GET /api/chat/keys?did= - * Fetch public key for a user - */ -export async function GET(request: NextRequest) { - try { - const { searchParams } = new URL(request.url); - const did = searchParams.get('did'); - - if (!did) { - return NextResponse.json({ error: 'Missing did parameter' }, { status: 400 }); - } - - console.log('[Chat Keys GET] Looking for DID:', did); - - const bundle = await db.query.chatDeviceBundles.findFirst({ - where: eq(chatDeviceBundles.did, did), - orderBy: (bundles, { desc }) => [desc(bundles.lastSeenAt)], - }); - - console.log('[Chat Keys GET] Found local bundle:', bundle ? 'YES' : 'NO'); - if (bundle) { - console.log('[Chat Keys GET] Bundle data:', { - userId: bundle.userId, - did: bundle.did, - deviceId: bundle.deviceId, - hasIdentityKey: !!bundle.identityKey, - }); - - // For libsodium, we just need the public key - return NextResponse.json({ - publicKey: bundle.identityKey, - }); - } - - // If not found locally, check remote identity cache - const cached = await db.query.remoteIdentityCache.findFirst({ - where: and( - eq(remoteIdentityCache.did, did), - gt(remoteIdentityCache.expiresAt, new Date()) - ), - }); - - // DEBUG: Force refresh for now to fix development key mismatches - // if (cached) { - // console.log('[Chat Keys GET] Found cached remote key'); - // return NextResponse.json({ - // publicKey: cached.publicKey, - // }); - // } - - // If not in cache, try to resolve DID to a node - console.log('[Chat Keys GET] resolving DID to node...'); - const handleEntry = await db.query.handleRegistry.findFirst({ - where: eq(handleRegistry.did, did), - }); - - if (handleEntry) { - const { nodeDomain } = handleEntry; - console.log('[Chat Keys GET] Resolved to node:', nodeDomain); - - // Fetch from remote node - const remoteUrl = `https://${nodeDomain}/api/chat/keys?did=${encodeURIComponent(did)}`; - console.log('[Chat Keys GET] Fetching from remote:', remoteUrl); - - try { - const remoteRes = await fetch(remoteUrl); - if (remoteRes.ok) { - const data = await remoteRes.json(); - if (data.publicKey) { - console.log('[Chat Keys GET] Successfully fetched remote key'); - - // Cache it - await db.insert(remoteIdentityCache).values({ - did: did, - publicKey: data.publicKey, - fetchedAt: new Date(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours - }).onConflictDoUpdate({ - target: remoteIdentityCache.did, - set: { - publicKey: data.publicKey, - fetchedAt: new Date(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), - } - }); - - return NextResponse.json({ - publicKey: data.publicKey, - }); - } - } else { - console.warn('[Chat Keys GET] Remote fetch failed:', remoteRes.status, remoteRes.statusText); - } - } catch (err) { - console.error('[Chat Keys GET] Remote fetch error:', err); - } - - // FALLBACK: Try fetching from Swarm User Profile (standard endpoint) - if (!cached) { - console.log('[Chat Keys GET] Trying fallback to Swarm Profile...'); - const swarmUrl = `https://${nodeDomain}/api/swarm/users/${handleEntry.handle}`; - console.log('[Chat Keys GET] Fetching Swarm Profile:', swarmUrl); - - try { - const swarmRes = await fetch(swarmUrl); - if (swarmRes.ok) { - const data = await swarmRes.json(); - const chatKey = data.profile?.chatPublicKey; - - if (chatKey) { - console.log('[Chat Keys GET] Found key in Swarm Profile'); - - // Cache it - await db.insert(remoteIdentityCache).values({ - did: did, - publicKey: chatKey, - fetchedAt: new Date(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours - }).onConflictDoUpdate({ - target: remoteIdentityCache.did, - set: { - publicKey: chatKey, - fetchedAt: new Date(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), - } - }); - - return NextResponse.json({ - publicKey: chatKey, - }); - } else { - console.warn('[Chat Keys GET] Swarm Profile found but no chatPublicKey'); - } - } else { - console.warn('[Chat Keys GET] Swarm Profile fetch failed:', swarmRes.status); - } - } catch (err) { - console.error('[Chat Keys GET] Swarm Profile fetch error:', err); - } - } - } else { - console.log('[Chat Keys GET] DID not found in handle registry'); - } - - return NextResponse.json({ error: 'No keys found' }, { status: 404 }); - } catch (error: any) { - console.error('[Chat Keys] Failed to fetch keys:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} - -/** - * POST /api/chat/keys - * Publish public key - * - * Body: { - * publicKey: string (base64) - * } - */ -export async function POST(request: NextRequest) { - try { - const user = await requireAuth(); - - console.log('[Chat Keys POST] User:', user.handle, 'DID:', user.did, 'UserID:', user.id); - - const body = await request.json(); - const { publicKey } = body; - - console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING'); - - if (!publicKey) { - return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 }); - } - - // Check if bundle exists - const existing = await db.query.chatDeviceBundles.findFirst({ - where: and( - eq(chatDeviceBundles.userId, user.id), - eq(chatDeviceBundles.deviceId, '1') - ) - }); - - console.log('[Chat Keys POST] Existing bundle found:', existing ? 'YES' : 'NO'); - - if (existing) { - // Update existing bundle - console.log('[Chat Keys POST] Updating existing bundle...'); - await db.update(chatDeviceBundles) - .set({ - identityKey: publicKey, - did: user.did, // Update DID too in case it changed - lastSeenAt: new Date(), - }) - .where( - and( - eq(chatDeviceBundles.userId, user.id), - eq(chatDeviceBundles.deviceId, '1') - ) - ); - console.log('[Chat Keys POST] Updated existing key'); - } else { - console.log('[Chat Keys POST] Inserted new key'); - await db.insert(chatDeviceBundles).values({ - userId: user.id, - did: user.did, - deviceId: '1', - identityKey: publicKey, - signedPreKey: 'libsodium', // Placeholder - registrationId: 1, - signature: 'libsodium', - }); - console.log('[Chat Keys POST] Inserted new key'); - } - - // Sync to users table too for profile discovery - await db.update(users) - .set({ chatPublicKey: publicKey }) - .where(eq(users.id, user.id)); - - console.log('[Chat Keys POST] Key published successfully for', user.handle); - return NextResponse.json({ success: true }); - } catch (error: any) { - console.error('[Chat Keys] Failed to publish key:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index 690c8d3..71a1894 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -3,108 +3,155 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; +import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature'; /** * POST /api/chat/receive * Endpoint for receiving federated chat messages from other nodes. - * - * Body: { - * senderDid: string, - * senderHandle: string, // user@domain - * senderDisplayName: string, - * senderAvatarUrl: string, - * senderNodeDomain: string, - * recipientDid: string, - * encryptedContent: string (base64 JSON of {senderPublicKey, ciphertext, nonce, recipientDid}), - * sentAt: string (ISO date) - * } + * Expects a SignedAction payload from the sender. */ export async function POST(request: NextRequest) { try { - const body = await request.json(); - const { - senderDid, - senderHandle, - senderDisplayName, - senderAvatarUrl, - senderNodeDomain, - recipientDid, - encryptedContent - } = body; + const signedAction: SignedAction = await request.json(); + const { did, handle, data } = signedAction; + const { recipientDid, content } = data || {}; - // Basic validation - if (!senderDid || !senderHandle || !recipientDid || !encryptedContent || !senderNodeDomain) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + if (!did || !handle || !recipientDid || !content) { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); } - console.log(`[Chat Receive] From: ${senderHandle} (${senderNodeDomain}), To: ${recipientDid} [${encryptedContent.substring(0, 20)}...]`); + console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`); - // Ensure remote handle is fully qualified - let finalSenderHandle = senderHandle; - if (!finalSenderHandle.includes('@') && senderNodeDomain) { - finalSenderHandle = `${senderHandle}@${senderNodeDomain}`; + // 1. Resolve Sender Public Key + let senderUser = await db.query.users.findFirst({ + where: eq(users.did, did) + }); + + let publicKey = senderUser?.publicKey; + let senderDisplayName = senderUser?.displayName || handle; + let senderAvatarUrl = senderUser?.avatarUrl; + let senderNodeDomain: string | null = null; + + if (!senderUser) { + // Unknown user - likely remote. We need to fetch their profile to get the public key. + // Derive domain from handle if possible + if (handle.includes('@')) { + const parts = handle.split('@'); + senderNodeDomain = parts[parts.length - 1]; + } else { + // Try handle registry (though we likely don't have it if we don't have the user) + const registryEntry = await db.query.handleRegistry.findFirst({ + where: eq(handleRegistry.did, did) + }); + if (registryEntry) senderNodeDomain = registryEntry.nodeDomain; + } + + if (senderNodeDomain) { + try { + const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https'; + // Fetch profile from remote node + // Assuming /api/users/:handle convention + const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle; + const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`); + + if (res.ok) { + const profileData = await res.json(); + const remoteProfile = profileData.user; + if (remoteProfile && remoteProfile.publicKey) { + if (remoteProfile.did !== did) { + console.error('DID mismatch for remote user'); + } else { + publicKey = remoteProfile.publicKey; + senderDisplayName = remoteProfile.displayName || handle; + senderAvatarUrl = remoteProfile.avatarUrl; + } + } + } else { + console.error('Failed to fetch remote profile:', res.status); + } + } catch (e) { + console.error('Remote profile fetch failed:', e); + } + } } - // 1. Find local recipient + if (!publicKey) { + return NextResponse.json({ error: 'Could not resolve sender public key' }, { status: 401 }); + } + + // 2. Verify Signature + const isValid = await verifyActionSignature(signedAction, publicKey); + if (!isValid) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 403 }); + } + + // 3. Find Local Recipient const recipientUser = await db.query.users.findFirst({ where: eq(users.did, recipientDid) }); if (!recipientUser) { - return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); + return NextResponse.json({ error: 'Recipient not found on this node' }, { status: 404 }); } - // 2. Find or Create Conversation - // For the RECIPIENT, the conversation is with the SENDER (Remote) + // 4. Find or Create Conversation + // For the RECIPIENT, the conversation is with the SENDER + // Use full handle + const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle); + let conversation = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, recipientUser.id), - eq(chatConversations.participant2Handle, finalSenderHandle) + eq(chatConversations.participant2Handle, fullSenderHandle) ) }); if (!conversation) { const [newConv] = await db.insert(chatConversations).values({ participant1Id: recipientUser.id, - participant2Handle: finalSenderHandle, + participant2Handle: fullSenderHandle, lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted Message]' + lastMessagePreview: content.slice(0, 50) }).returning(); conversation = newConv; + } else { + // Update preview + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: content.slice(0, 50), + updatedAt: new Date() + }) + .where(eq(chatConversations.id, conversation.id)); } - // 3. Store Message + // 5. Store Message await db.insert(chatMessages).values({ conversationId: conversation.id, - senderHandle: finalSenderHandle, - senderDisplayName: senderDisplayName || senderHandle, + senderHandle: fullSenderHandle, + senderDisplayName: senderDisplayName, senderAvatarUrl: senderAvatarUrl, senderNodeDomain: senderNodeDomain, - senderDid: senderDid, - encryptedContent: encryptedContent, + senderDid: did, + content: content, + encryptedContent: '', deliveredAt: new Date(), }); - // 4. Update conversation timestamp - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted Message]' - }) - .where(eq(chatConversations.id, conversation.id)); - - // 5. Upsert sender into HandleRegistry ensures we can reply/fetch keys later - await db.insert(handleRegistry).values({ - handle: finalSenderHandle, // user@domain - did: senderDid, - nodeDomain: senderNodeDomain - }).onConflictDoUpdate({ - target: handleRegistry.handle, - set: { - did: senderDid, + // 6. Update Registry (to ensure we can reply efficiently) + if (senderNodeDomain) { + await db.insert(handleRegistry).values({ + handle: fullSenderHandle, // user@domain + did: did, nodeDomain: senderNodeDomain - } - }); + }).onConflictDoUpdate({ + target: handleRegistry.handle, + set: { + did: did, + nodeDomain: senderNodeDomain + } + }); + } return NextResponse.json({ success: true }); diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index cbb7a61..9dc43a6 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -1,31 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; -import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; -import { requireAuth } from '@/lib/auth'; +import { chatConversations, chatMessages, users, handleRegistry, follows } from '@/db/schema'; +import { requireSignedAction } from '@/lib/auth/verify-signature'; import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; + +const chatSendSchema = z.object({ + recipientDid: z.string(), + recipientHandle: z.string(), + content: z.string().min(1).max(5000), +}); /** * POST /api/chat/send - * Store encrypted message (server never decrypts) - * - * Body: { - * recipientDid: string, - * senderPublicKey: string (base64), - * ciphertext: string (base64), - * nonce: string (base64), - * recipientHandle?: string - * } + * Send a signed chat message (verified with DID) + * Stores plain text content. */ export async function POST(request: NextRequest) { try { - const user = await requireAuth(); + // Parse the signed action from the request body + const signedAction = await request.json(); - const body = await request.json(); - const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body; + // Strictly verify the signature and get the user + const user = await requireSignedAction(signedAction); - if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } + // Extract message data + const data = chatSendSchema.parse(signedAction.data); + const { recipientDid, recipientHandle, content } = data; // Check if recipient is local const recipientUser = await db.query.users.findFirst({ @@ -33,9 +34,30 @@ export async function POST(request: NextRequest) { }); if (recipientUser) { + // Reject if recipient is a bot + if (recipientUser.isBot) { + return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 }); + } + + // Check DM privacy settings + if (recipientUser.dmPrivacy === 'none') { + return NextResponse.json({ error: 'This user does not accept direct messages' }, { status: 403 }); + } else if (recipientUser.dmPrivacy === 'following') { + // Check if recipient follows the sender + const isFollowingSender = await db.query.follows.findFirst({ + where: and( + eq(follows.followerId, recipientUser.id), + eq(follows.followingId, user.id) + ) + }); + if (!isFollowingSender) { + return NextResponse.json({ error: 'This user only accepts messages from accounts they follow' }, { status: 403 }); + } + } // LOCAL RECIPIENT - // Ensure conversations exist + // Ensure conversations exist for both sides + // 1. Recipient's Inbox (Recipient -> User) let recipientConv = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, recipientUser.id), @@ -48,11 +70,21 @@ export async function POST(request: NextRequest) { participant1Id: recipientUser.id, participant2Handle: user.handle, lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted]' + lastMessagePreview: content.slice(0, 50) }).returning(); recipientConv = newConv; + } else { + // Update preview + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: content.slice(0, 50), + updatedAt: new Date() + }) + .where(eq(chatConversations.id, recipientConv.id)); } + // 2. Sender's Sent Box (User -> Recipient) let senderConv = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, user.id), @@ -65,21 +97,20 @@ export async function POST(request: NextRequest) { participant1Id: user.id, participant2Handle: recipientUser.handle, lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted]' + lastMessagePreview: content.slice(0, 50) }).returning(); senderConv = newConv; + } else { + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: content.slice(0, 50), + updatedAt: new Date() + }) + .where(eq(chatConversations.id, senderConv.id)); } - // Store encrypted message (libsodium format) - // Include recipientDid so sender can decrypt their own sent messages - const messageData = { - senderPublicKey, - recipientDid, // Add this so sender knows who to decrypt with - ciphertext, - nonce, - }; - - // Create message for recipient + // Create message for recipient (Inbox) await db.insert(chatMessages).values({ conversationId: recipientConv.id, senderHandle: user.handle, @@ -87,11 +118,13 @@ export async function POST(request: NextRequest) { senderAvatarUrl: user.avatarUrl, senderNodeDomain: null, senderDid: user.did, - encryptedContent: JSON.stringify(messageData), + content: content, + // Encrypted fields are null for plain text chat + encryptedContent: '', deliveredAt: new Date(), }); - // Create message for sender + // Create message for sender (Sent) await db.insert(chatMessages).values({ conversationId: senderConv.id, senderHandle: user.handle, @@ -99,61 +132,44 @@ export async function POST(request: NextRequest) { senderAvatarUrl: user.avatarUrl, senderNodeDomain: null, senderDid: user.did, - encryptedContent: JSON.stringify(messageData), + content: content, + encryptedContent: '', deliveredAt: new Date(), + readAt: new Date() // Sender has read their own message }); return NextResponse.json({ success: true }); } else { // REMOTE RECIPIENT - const { handleRegistry } = await import('@/db/schema'); // dynamic import or add to top // 1. Resolve recipient node const registryEntry = await db.query.handleRegistry.findFirst({ where: eq(handleRegistry.did, recipientDid) }); - if (!registryEntry) { - console.error('Recipient DID not found in registry:', recipientDid); - return NextResponse.json({ error: 'Recipient not found in registry' }, { status: 404 }); + // If not in registry, try to parse from handle if it has domain + let targetDomain: string | null = registryEntry?.nodeDomain || null; + let targetHandle = recipientHandle; + + if (!targetDomain && recipientHandle.includes('@')) { + const parts = recipientHandle.split('@'); + targetDomain = parts[parts.length - 1]; } - const targetDomain = registryEntry.nodeDomain; - // Ensure handle is fully qualified for remote users - let targetHandle = registryEntry.handle; - if (!targetHandle.includes('@') && targetDomain) { - targetHandle = `${targetHandle}@${targetDomain}`; + if (!targetDomain) { + console.error('Recipient node domain not found for:', recipientHandle); + return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 }); } console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`); - // 2. Prepare Payload - const messageData = { - senderPublicKey, - recipientDid, - ciphertext, - nonce, - }; - const encryptedContent = JSON.stringify(messageData); - - const payload = { - senderDid: user.did, - senderHandle: user.handle, - senderDisplayName: user.displayName, - senderAvatarUrl: user.avatarUrl, - senderNodeDomain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'dev.syn.quest', // Current node domain - recipientDid, - encryptedContent, - sentAt: new Date().toISOString() - }; - - // 3. Send to Remote Node + // 2. Send to Remote Node (Forward the Signed Action) try { const protocol = targetDomain.includes('localhost') ? 'http' : 'https'; const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(signedAction) // Forward the user's signed intent }); if (!res.ok) { @@ -166,23 +182,31 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Failed to contact remote node' }, { status: 504 }); } - // 4. Store "Sent" copy locally + // 3. Store "Sent" copy locally // Ensure conversation exists locally let senderConv = await db.query.chatConversations.findFirst({ where: and( eq(chatConversations.participant1Id, user.id), - eq(chatConversations.participant2Handle, targetHandle) + eq(chatConversations.participant2Handle, recipientHandle) ) }); if (!senderConv) { const [newConv] = await db.insert(chatConversations).values({ participant1Id: user.id, - participant2Handle: targetHandle, + participant2Handle: recipientHandle, lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted]' + lastMessagePreview: content.slice(0, 50) }).returning(); senderConv = newConv; + } else { + await db.update(chatConversations) + .set({ + lastMessageAt: new Date(), + lastMessagePreview: content.slice(0, 50), + updatedAt: new Date() + }) + .where(eq(chatConversations.id, senderConv.id)); } await db.insert(chatMessages).values({ @@ -190,17 +214,24 @@ export async function POST(request: NextRequest) { senderHandle: user.handle, senderDisplayName: user.displayName, senderAvatarUrl: user.avatarUrl, - senderNodeDomain: null, // It's ME, so null + senderNodeDomain: null, senderDid: user.did, - encryptedContent: encryptedContent, + content: content, + encryptedContent: '', deliveredAt: new Date(), + readAt: new Date() }); return NextResponse.json({ success: true }); } } catch (error: any) { - console.error('Send failed:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error('Send chat failed:', error); + + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); + } + + return NextResponse.json({ error: error.message || 'Failed to send message' }, { status: 500 }); } } \ No newline at end of file diff --git a/src/app/api/debug/check-chat-keys/route.ts b/src/app/api/debug/check-chat-keys/route.ts deleted file mode 100644 index bf57b5a..0000000 --- a/src/app/api/debug/check-chat-keys/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db, users, chatDeviceBundles } from '@/db'; -import { eq } from 'drizzle-orm'; - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const handle = searchParams.get('handle'); - - if (!handle) { - return NextResponse.json({ error: 'Missing handle parameter' }, { status: 400 }); - } - - // Find user - const user = await db.query.users.findFirst({ - where: eq(users.handle, handle.toLowerCase()), - }); - - if (!user) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); - } - - // Check for device bundles - const bundles = await db.query.chatDeviceBundles.findMany({ - where: eq(chatDeviceBundles.userId, user.id), - with: { - oneTimeKeys: true, - }, - }); - - return NextResponse.json({ - user: { - id: user.id, - handle: user.handle, - did: user.did, - hasChatPublicKey: !!user.chatPublicKey, - }, - bundles: bundles.map(b => ({ - deviceId: b.deviceId, - identityKey: b.identityKey?.substring(0, 20) + '...', - hasSignedPreKey: !!b.signedPreKey, - oneTimeKeysCount: b.oneTimeKeys.length, - })), - bundleCount: bundles.length, - }); - } catch (error) { - console.error('Check chat keys error:', error); - return NextResponse.json({ error: 'Failed to check keys' }, { status: 500 }); - } -} diff --git a/src/app/api/debug/check-keys/route.ts b/src/app/api/debug/check-keys/route.ts deleted file mode 100644 index 84461f9..0000000 --- a/src/app/api/debug/check-keys/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatDeviceBundles } from '@/db/schema'; - -export async function GET() { - try { - const bundles = await db.select({ - did: chatDeviceBundles.did, - userId: chatDeviceBundles.userId, - deviceId: chatDeviceBundles.deviceId, - identityKey: chatDeviceBundles.identityKey, - createdAt: chatDeviceBundles.createdAt, - }).from(chatDeviceBundles).orderBy(chatDeviceBundles.createdAt); - - return NextResponse.json({ bundles, count: bundles.length }); - } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} diff --git a/src/app/api/debug/clear-chat-keys/route.ts b/src/app/api/debug/clear-chat-keys/route.ts deleted file mode 100644 index 163e738..0000000 --- a/src/app/api/debug/clear-chat-keys/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/db'; -import { chatDeviceBundles } from '@/db/schema'; - -export async function POST() { - try { - console.log('[Debug] Clearing all chat device bundles...'); - - await db.delete(chatDeviceBundles); - - console.log('[Debug] Cleared all chat device bundles'); - - return NextResponse.json({ success: true, message: 'Cleared all chat keys' }); - } catch (error: any) { - console.error('[Debug] Failed to clear keys:', error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 9840b59..b1f3414 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -65,35 +65,61 @@ export async function GET(request: Request) { } } + const isHandleSearch = query.trim().startsWith('@'); const searchPattern = `%${localSearchQuery}%`; let searchUsers: SearchUser[] = []; let searchPosts: typeof posts.$inferSelect[] = []; // Search users if (type === 'all' || type === 'users') { - const userConditions = and( - or( - ilike(users.handle, searchPattern), - ilike(users.displayName, searchPattern), - ilike(users.bio, searchPattern) - ), - eq(users.isSuspended, false), - eq(users.isSilenced, false) - ); - const localUsers = await db.select({ - id: users.id, - handle: users.handle, - displayName: users.displayName, - avatarUrl: users.avatarUrl, - bio: users.bio, - isBot: users.isBot, - }) - .from(users) - .where(userConditions) - .limit(limit); - - // Filter out remote placeholder users (those with @ in handle) - searchUsers = localUsers.filter(u => !u.handle.includes('@')); + if (isHandleSearch) { + // Try exact match first + const exactMatch = await db.select({ + id: users.id, + handle: users.handle, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, + isBot: users.isBot, + }) + .from(users) + .where(and( + eq(users.handle, localSearchQuery), + eq(users.isSuspended, false), + eq(users.isSilenced, false) + )) + .limit(1); + + if (exactMatch.length > 0) { + searchUsers = exactMatch; + } + } + + if (searchUsers.length === 0) { + const userConditions = and( + or( + ilike(users.handle, searchPattern), + ilike(users.displayName, searchPattern), + ilike(users.bio, searchPattern) + ), + eq(users.isSuspended, false), + eq(users.isSilenced, false) + ); + const localUsers = await db.select({ + id: users.id, + handle: users.handle, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, + isBot: users.isBot, + }) + .from(users) + .where(userConditions) + .limit(limit); + + // Filter out remote placeholder users (those with @ in handle) + searchUsers = localUsers.filter(u => !u.handle.includes('@')); + } } // Swarm user lookup (exact handle@domain queries) diff --git a/src/app/api/swarm/chat/conversations/route.ts b/src/app/api/swarm/chat/conversations/route.ts index 47f401f..0f655ed 100644 --- a/src/app/api/swarm/chat/conversations/route.ts +++ b/src/app/api/swarm/chat/conversations/route.ts @@ -54,7 +54,6 @@ export async function GET(request: NextRequest) { handle: participant2Handle, displayName: participant2Handle, avatarUrl: null as string | null, - chatPublicKey: null as string | null, }; // Try to get cached user info @@ -67,20 +66,22 @@ export async function GET(request: NextRequest) { handle: cachedUser.handle, displayName: cachedUser.displayName || cachedUser.handle, avatarUrl: cachedUser.avatarUrl, - chatPublicKey: cachedUser.chatPublicKey, // ECDH key for E2E chat }; } return { ...conv, - participant2: participant2Info, + participant2: { + ...participant2Info, + isBot: cachedUser?.isBot || false, + }, unreadCount: Number(unreadCount[0]?.count || 0), }; }) ); return NextResponse.json({ - conversations: conversationsWithUnread, + conversations: conversationsWithUnread.filter(c => !c.participant2.isBot), }); } catch (error) { console.error('List conversations error:', error); diff --git a/src/app/api/swarm/chat/inbox/route.ts b/src/app/api/swarm/chat/inbox/route.ts deleted file mode 100644 index 7a2edcf..0000000 --- a/src/app/api/swarm/chat/inbox/route.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Swarm Chat Inbox - * - * POST: Receives chat messages from other swarm nodes - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { db, users, chatConversations, chatMessages } from '@/db'; -import { eq, and } from 'drizzle-orm'; -import { z } from 'zod'; -import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; - -const chatMessageSchema = z.object({ - messageId: z.string(), - senderHandle: z.string(), - senderDisplayName: z.string().optional(), - senderAvatarUrl: z.string().optional(), - senderNodeDomain: z.string(), - recipientHandle: z.string(), - encryptedContent: z.string(), - timestamp: z.string(), - signature: z.string().optional(), -}); - -export async function POST(request: NextRequest) { - try { - if (!db) { - return NextResponse.json({ error: 'Database not available' }, { status: 503 }); - } - - const body = await request.json(); - console.log('[Swarm Inbox] Received body keys:', Object.keys(body), 'action:', body.action); - - // Check if this is a V2 encrypted envelope (has 'action' and 'data' fields) - // MUST check BEFORE Zod validation to avoid validation errors - if (body.action === 'chat.deliver' && body.data) { - // V2 E2EE Message - store in chatInbox - const { recipientDid, recipientDeviceId, ciphertext } = body.data; - - if (!recipientDid || !ciphertext) { - return NextResponse.json({ error: 'Invalid V2 payload' }, { status: 400 }); - } - - // Find recipient by DID - const recipient = await db.query.users.findFirst({ - where: eq(users.did, recipientDid) - }); - - if (!recipient) { - return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); - } - - // Import schemas - const { chatInbox, chatConversations, chatMessages } = await import('@/db/schema'); - - // Store in V2 inbox - await db.insert(chatInbox).values({ - senderDid: body.did, // From signed envelope - recipientDid, - recipientDeviceId: recipientDeviceId || null, - envelope: JSON.stringify(body), - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) - }); - - // Also create conversation and message for recipient UI - // Extract sender info from envelope - const senderHandle = body.handle || 'unknown'; - const senderNodeDomain = body.data?.senderNodeDomain || 'unknown'; - const senderFullHandle = senderNodeDomain !== 'unknown' - ? `${senderHandle}@${senderNodeDomain}` - : senderHandle; - - console.log('[Swarm Chat V2] Sender info:', { senderHandle, senderNodeDomain, senderFullHandle }); - - // Get or create conversation for recipient - let conversation = await db.query.chatConversations.findFirst({ - where: and( - eq(chatConversations.participant1Id, recipient.id), - eq(chatConversations.participant2Handle, senderFullHandle) - ) - }); - - if (!conversation) { - const [newConv] = await db.insert(chatConversations).values({ - participant1Id: recipient.id, - participant2Handle: senderFullHandle, - lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted message]' - }).returning(); - conversation = newConv; - console.log(`[Swarm Chat V2] Created conversation for recipient:`, conversation.id); - } else { - // Update last message time - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: '[Encrypted message]' - }) - .where(eq(chatConversations.id, conversation.id)); - } - - // Store message reference so it appears in UI - // Store full envelope data as JSON so we can decrypt later - const envelopeData = { - did: body.did, - handle: body.handle, - ciphertext: body.data.ciphertext - }; - - const messageId = crypto.randomUUID(); - await db.insert(chatMessages).values({ - conversationId: conversation.id, - senderHandle: senderFullHandle, - senderDisplayName: null, // Unknown until decrypted - senderAvatarUrl: null, - senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null, - encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption - senderChatPublicKey: null, - swarmMessageId: `swarm:v2:${messageId}`, - deliveredAt: new Date(), - readAt: null, - }); - - console.log(`[Swarm Chat V2] Received encrypted message for ${recipientDid}, conversation:`, conversation.id); - - return NextResponse.json({ - success: true, - message: 'V2 message received', - version: 2 - }); - } - - // V1 Legacy Message Format - validate with Zod - const data = chatMessageSchema.parse(body) as SwarmChatMessagePayload; - - // Find the recipient (local user) - const recipient = await db.query.users.findFirst({ - where: eq(users.handle, data.recipientHandle.toLowerCase()), - }); - - if (!recipient) { - return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); - } - - if (recipient.isSuspended) { - return NextResponse.json({ error: 'Recipient not available' }, { status: 404 }); - } - - // Check if message already exists (prevent duplicates) - const swarmMessageId = `swarm:${data.senderNodeDomain}:${data.messageId}`; - const existingMessage = await db.query.chatMessages.findFirst({ - where: eq(chatMessages.swarmMessageId, swarmMessageId), - }); - - if (existingMessage) { - return NextResponse.json({ - success: true, - message: 'Message already received', - }); - } - - // Get or create conversation - const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`; - let conversation = await db.query.chatConversations.findFirst({ - where: and( - eq(chatConversations.participant1Id, recipient.id), - eq(chatConversations.participant2Handle, senderFullHandle) - ), - }); - - if (!conversation) { - const [newConversation] = await db.insert(chatConversations).values({ - participant1Id: recipient.id, - participant2Handle: senderFullHandle, - lastMessageAt: new Date(data.timestamp), - lastMessagePreview: '[Encrypted message]', - }).returning(); - conversation = newConversation; - } - - // Store the message - const [newMessage] = await db.insert(chatMessages).values({ - conversationId: conversation.id, - senderHandle: senderFullHandle, - senderDisplayName: data.senderDisplayName, - senderAvatarUrl: data.senderAvatarUrl, - senderNodeDomain: data.senderNodeDomain, - encryptedContent: data.encryptedContent, - swarmMessageId, - deliveredAt: new Date(), - createdAt: new Date(data.timestamp), - }).returning(); - - // Update conversation last message - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(data.timestamp), - lastMessagePreview: '[Encrypted message]', - updatedAt: new Date(), - }) - .where(eq(chatConversations.id, conversation.id)); - - console.log(`[Swarm Chat] Received message from ${senderFullHandle} to ${data.recipientHandle}`); - - return NextResponse.json({ - success: true, - message: 'Message received', - messageId: newMessage.id, - }); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 }); - } - console.error('Swarm chat inbox error:', error); - return NextResponse.json({ error: 'Failed to receive message' }, { status: 500 }); - } -} diff --git a/src/app/api/swarm/chat/messages/route.ts b/src/app/api/swarm/chat/messages/route.ts index bce9c4f..00fc4f5 100644 --- a/src/app/api/swarm/chat/messages/route.ts +++ b/src/app/api/swarm/chat/messages/route.ts @@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, chatConversations, chatMessages, users } from '@/db'; import { eq, desc, and, lt, isNull } from 'drizzle-orm'; import { getSession } from '@/lib/auth'; -import { decryptMessage } from '@/lib/swarm/chat-crypto'; + export async function GET(request: NextRequest) { try { @@ -43,15 +43,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Conversation not found' }, { status: 404 }); } - // Get user's private key for decryption - const user = await db.query.users.findFirst({ - where: eq(users.id, session.user.id), - }); - - if (!user?.privateKeyEncrypted) { - return NextResponse.json({ error: 'Cannot decrypt messages' }, { status: 500 }); - } - // Build query with cursor-based pagination const baseCondition = eq(chatMessages.conversationId, conversationId); const whereCondition = cursor @@ -65,90 +56,16 @@ export async function GET(request: NextRequest) { limit, }); - // Get recipient info for sent messages - const recipientHandle = conversation.participant2Handle; - let recipientPublicKey: string | null = null; - - console.log('[Messages API] Fetching recipient key for:', recipientHandle); - - // Check if this is a remote user (has @domain) - const isRemote = recipientHandle.includes('@'); - - if (isRemote) { - // Remote user - fetch from their node - const [handle, domain] = recipientHandle.split('@'); - try { - const protocol = domain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${domain}/api/users/${handle}`); - if (response.ok) { - const data = await response.json(); - recipientPublicKey = data.user?.chatPublicKey || null; - console.log('[Messages API] Fetched remote recipient key:', !!recipientPublicKey); - } - } catch (error) { - console.error('[Messages API] Failed to fetch remote recipient key:', error); - } - } else { - // Local user - const recipientUser = await db.query.users.findFirst({ - where: eq(users.handle, recipientHandle), - }); - recipientPublicKey = recipientUser?.chatPublicKey || null; - } - - console.log('[Messages API] Recipient public key found:', !!recipientPublicKey); - - // Get sender DID for received messages - const senderDids = new Map(); - for (const msg of messages) { + const messagesMapped = messages.map((msg) => { const isSentByMe = msg.senderHandle === session.user.handle; - if (!isSentByMe && !senderDids.has(msg.senderHandle)) { - // Try to get DID for this sender - try { - const isRemote = msg.senderHandle.includes('@'); - if (isRemote) { - const [handle, domain] = msg.senderHandle.split('@'); - const protocol = domain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${domain}/api/users/${handle}`); - if (response.ok) { - const data = await response.json(); - if (data.user?.did) { - senderDids.set(msg.senderHandle, data.user.did); - } - } - } else { - const senderUser = await db.query.users.findFirst({ - where: eq(users.handle, msg.senderHandle), - }); - if (senderUser?.did) { - senderDids.set(msg.senderHandle, senderUser.did); - } - } - } catch (e) { - console.error('[Messages API] Failed to resolve sender DID:', e); - } - } - } - const messagesWithDecryption = messages.map((msg) => { - const isSentByMe = msg.senderHandle === session.user.handle; - - const senderPubKey = isSentByMe ? recipientPublicKey : msg.senderChatPublicKey; - - console.log('[Messages API] Message:', msg.id, 'isSentByMe:', isSentByMe, 'senderPubKey:', !!senderPubKey, 'msgSenderChatPubKey:', !!msg.senderChatPublicKey); - return { id: msg.id, senderHandle: msg.senderHandle, senderDisplayName: msg.senderDisplayName, senderAvatarUrl: msg.senderAvatarUrl, - senderDid: isSentByMe ? undefined : senderDids.get(msg.senderHandle), // Add DID for received messages - // For decryption: - // - Sent messages: need recipient's public key - // - Received messages: need sender's public key - senderPublicKey: senderPubKey, - isE2E: !!msg.senderChatPublicKey || (isSentByMe && !!recipientPublicKey), - encryptedContent: msg.encryptedContent, // This is now the full envelope JSON + senderDid: msg.senderDid, + content: msg.content, deliveredAt: msg.deliveredAt, readAt: msg.readAt, createdAt: msg.createdAt, @@ -157,7 +74,7 @@ export async function GET(request: NextRequest) { }); return NextResponse.json({ - messages: messagesWithDecryption.reverse(), // Oldest first for display + messages: messagesMapped.reverse(), // Oldest first for display nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null, }); } catch (error) { diff --git a/src/app/api/swarm/chat/send/route.ts b/src/app/api/swarm/chat/send/route.ts deleted file mode 100644 index b453fe4..0000000 --- a/src/app/api/swarm/chat/send/route.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Swarm Chat Send - * - * POST: Send a chat message to another user (local or remote) - */ - -import { NextRequest, NextResponse } from 'next/server'; -import { db, users, chatConversations, chatMessages } from '@/db'; -import { eq, and } from 'drizzle-orm'; -import { z } from 'zod'; -import { getSession } from '@/lib/auth'; -import { encryptMessage } from '@/lib/swarm/chat-crypto'; -import type { SwarmChatMessagePayload } from '@/lib/swarm/chat-types'; - -const sendMessageSchema = z.object({ - recipientHandle: z.string(), - // 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) { - console.log('[Chat Send] Starting request processing'); - try { - if (!db) { - console.error('[Chat Send] Database connection missing'); - return NextResponse.json({ error: 'Database not available' }, { status: 503 }); - } - - const session = await getSession(); - if (!session?.user) { - console.warn('[Chat Send] Unauthorized attempt'); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - console.log('[Chat Send] User authenticated:', session.user.id); - - let body; - try { - body = await request.json(); - } catch (e) { - console.error('[Chat Send] Failed to parse JSON body'); - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const parseResult = sendMessageSchema.safeParse(body); - if (!parseResult.success) { - console.error('[Chat Send] Schema validation failed:', parseResult.error); - return NextResponse.json({ error: 'Invalid input', details: parseResult.error.issues }, { status: 400 }); - } - const data = parseResult.data; - console.log('[Chat Send] Input validated. Recipient:', data.recipientHandle, 'Has senderPublicKey:', !!data.senderPublicKey); - - // Get sender info - const sender = await db.query.users.findFirst({ - where: eq(users.id, session.user.id), - }); - - if (!sender) { - console.error('[Chat Send] Sender not found in DB:', session.user.id); - return NextResponse.json({ error: 'Sender not found' }, { status: 404 }); - } - console.log('[Chat Send] Sender retrieved:', sender.handle); - - // Parse recipient handle (could be local or remote) - // Strip leading @ if present, then normalize - const recipientHandle = data.recipientHandle.toLowerCase().replace(/^@/, ''); - const isRemote = recipientHandle.includes('@'); - - let recipientUser: typeof users.$inferSelect | undefined; - let recipientPublicKey: string; - let recipientNodeDomain: string | null = null; - - if (isRemote) { - // Remote user - need to fetch their public key - // Format: handle@domain (e.g., matterbator@batorbros.bond) - const atIndex = recipientHandle.indexOf('@'); - const handle = recipientHandle.substring(0, atIndex); - const domain = recipientHandle.substring(atIndex + 1); - recipientNodeDomain = domain; - console.log('[Chat Send] Processing remote recipient:', handle, '@', domain); - - // Try to find cached remote user - recipientUser = await db.query.users.findFirst({ - where: eq(users.handle, recipientHandle), - }); - - // Check if we have a valid public key (not a placeholder) - const hasValidPublicKey = recipientUser?.publicKey?.startsWith('-----BEGIN'); - - if (!recipientUser || !hasValidPublicKey) { - // Fetch from remote node to get the real public key - try { - console.log('[Chat Send] Fetching remote user from node:', domain); - const protocol = domain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${domain}/api/users/${handle}`); - - if (!response.ok) { - console.error('[Chat Send] Remote user fetch failed. Status:', response.status); - return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); - } - - const remoteUserData = await response.json(); - const userData = remoteUserData.user || remoteUserData; - recipientPublicKey = userData.publicKey; - - if (!recipientPublicKey || !recipientPublicKey.startsWith('-----BEGIN')) { - console.error('[Chat Send] Remote user has no valid public key'); - return NextResponse.json({ error: 'Recipient does not support encrypted chat' }, { status: 400 }); - } - - if (recipientUser) { - // Update existing cached user with real public key - await db.update(users) - .set({ publicKey: recipientPublicKey }) - .where(eq(users.id, recipientUser.id)); - console.log('[Chat Send] Updated cached user with real public key'); - } else { - // Cache the remote user - const [newUser] = await db.insert(users).values({ - did: userData.did || `did:swarm:${domain}:${handle}`, - handle: recipientHandle, - displayName: userData.displayName, - avatarUrl: userData.avatarUrl, - publicKey: recipientPublicKey, - }).returning(); - recipientUser = newUser; - console.log('[Chat Send] Remote user cached'); - } - } catch (error) { - console.error('[Chat Send] Failed to fetch remote user:', error); - return NextResponse.json({ error: 'Failed to reach recipient node' }, { status: 503 }); - } - } else { - recipientPublicKey = recipientUser.publicKey; - console.log('[Chat Send] Remote user found in cache with valid key'); - } - } else { - // Local user - console.log('[Chat Send] Processing local recipient'); - recipientUser = await db.query.users.findFirst({ - where: eq(users.handle, recipientHandle), - }); - - if (!recipientUser) { - console.warn('[Chat Send] Local recipient not found:', recipientHandle); - return NextResponse.json({ error: 'Recipient not found' }, { status: 404 }); - } - - if (recipientUser.isSuspended) { - console.warn('[Chat Send] Local recipient suspended:', recipientHandle); - return NextResponse.json({ error: 'Recipient not available' }, { status: 404 }); - } - - recipientPublicKey = recipientUser.publicKey; - } - - // Handle encryption - either client-side (E2E) or server-side (legacy) - let encryptedContent: string; - 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 - let conversation = await db.query.chatConversations.findFirst({ - where: and( - eq(chatConversations.participant1Id, sender.id), - eq(chatConversations.participant2Handle, recipientHandle) - ), - }); - - if (!conversation) { - console.log('[Chat Send] Creating new conversation'); - const [newConversation] = await db.insert(chatConversations).values({ - participant1Id: sender.id, - participant2Handle: recipientHandle, - lastMessageAt: new Date(), - lastMessagePreview: 'New message', - }).returning(); - conversation = newConversation; - } - - // Store the message locally - const messageId = crypto.randomUUID(); - const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; - const swarmMessageId = `swarm:${nodeDomain}:${messageId}`; - - console.log('[Chat Send] Inserting message into DB, senderPublicKey from client:', !!data.senderPublicKey, 'from DB:', !!sender.chatPublicKey); - const [newMessage] = await db.insert(chatMessages).values({ - conversationId: conversation.id, - senderHandle: sender.handle, - senderDisplayName: sender.displayName, - senderAvatarUrl: sender.avatarUrl, - senderNodeDomain: null, // Local sender - encryptedContent, - senderEncryptedContent, - // Use client-provided key first, fall back to database - senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, - swarmMessageId, - deliveredAt: isRemote ? null : new Date(), // Delivered immediately if local - readAt: null, - }).returning(); - - // Update conversation - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: 'New message', - updatedAt: new Date(), - }) - .where(eq(chatConversations.id, conversation.id)); - - // For LOCAL recipients, create/update their conversation too - if (!isRemote && recipientUser) { - try { - console.log('[Chat Send] Creating reciprocal conversation for local recipient:', recipientUser.handle); - - // Check if recipient has a conversation with sender - let recipientConversation = await db.query.chatConversations.findFirst({ - where: and( - eq(chatConversations.participant1Id, recipientUser.id), - eq(chatConversations.participant2Handle, sender.handle) - ), - }); - - if (!recipientConversation) { - // Create conversation for recipient - const [newRecipientConv] = await db.insert(chatConversations).values({ - participant1Id: recipientUser.id, - participant2Handle: sender.handle, - lastMessageAt: new Date(), - lastMessagePreview: 'New message', - }).returning(); - recipientConversation = newRecipientConv; - console.log('[Chat Send] Created new conversation for recipient'); - } else { - // Update existing conversation - await db.update(chatConversations) - .set({ - lastMessageAt: new Date(), - lastMessagePreview: 'New message', - updatedAt: new Date(), - }) - .where(eq(chatConversations.id, recipientConversation.id)); - console.log('[Chat Send] Updated existing conversation for recipient'); - } - - // Insert message into recipient's conversation - await db.insert(chatMessages).values({ - conversationId: recipientConversation.id, - senderHandle: sender.handle, - senderDisplayName: sender.displayName, - senderAvatarUrl: sender.avatarUrl, - senderNodeDomain: null, - encryptedContent, - senderEncryptedContent, - senderChatPublicKey: data.senderPublicKey || sender.chatPublicKey, - swarmMessageId: `${swarmMessageId}-recipient`, // Make it unique for recipient's copy - deliveredAt: new Date(), - readAt: null, - }); - - console.log('[Chat Send] Reciprocal message created for local recipient'); - } catch (recipError) { - console.error('[Chat Send] Failed to create reciprocal conversation:', recipError); - // Don't fail the whole request - sender's message was still saved - } - } - - // If remote, send to their node - if (isRemote && recipientNodeDomain) { - // ... (remote logic remains similar but add logs) - console.log('[Chat Send] Dispatching to remote node:', recipientNodeDomain); - // ... existing remote send logic ... - // For brevity in this tool call, I'm keeping the original logic mostly intact but wrapped/logged. - // Re-implementing the block: - try { - const payload: SwarmChatMessagePayload = { - messageId, - senderHandle: sender.handle, - senderDisplayName: sender.displayName || undefined, - senderAvatarUrl: sender.avatarUrl || undefined, - senderNodeDomain: nodeDomain, - recipientHandle: recipientHandle.split('@')[0], - encryptedContent, - timestamp: new Date().toISOString(), - }; - - const protocol = recipientNodeDomain.includes('localhost') ? 'http' : 'https'; - const response = await fetch(`${protocol}://${recipientNodeDomain}/api/swarm/chat/inbox`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - // Mark as delivered - await db.update(chatMessages) - .set({ deliveredAt: new Date() }) - .where(eq(chatMessages.id, newMessage.id)); - console.log('[Chat Send] Remote delivery confirmed'); - } else { - console.warn('[Chat Send] Remote delivery failed. Status:', response.status); - } - } catch (error) { - console.error('[Chat Send] Failed to send message to remote node:', error); - // Message is still stored locally, will show as undelivered - } - } - - console.log('[Chat Send] Success'); - return NextResponse.json({ - success: true, - message: newMessage, - }); - } catch (error) { - if (error instanceof z.ZodError) { - // Should be caught by safeParse above, but just in case - return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); - } - console.error('[Chat Send] Unhandled error:', error); - return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }); - } -} diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts index 139d845..20b7cf7 100644 --- a/src/app/api/swarm/users/[handle]/route.ts +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -22,7 +22,7 @@ export interface SwarmUserProfile { isBot?: boolean; botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain") nodeDomain: string; - chatPublicKey?: string; + publicKey?: string; // Signing key for verifying actions did?: string; } @@ -93,7 +93,7 @@ export async function GET(request: NextRequest, context: RouteContext) { isBot: user.isBot || undefined, botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined, nodeDomain, - chatPublicKey: user.chatPublicKey || undefined, + publicKey: user.publicKey, // Expose signing key did: user.did || undefined, }; diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index 5d9f611..d7129c0 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; -import { db, users } from '@/db'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; +import { getSession } from '@/lib/auth'; +import { db, users, follows } from '@/db'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -62,7 +63,6 @@ export async function GET(request: Request, context: RouteContext) { isSwarm: true, nodeDomain: remoteDomain, isBot: profile.isBot || false, - chatPublicKey: profile.chatPublicKey, did: profile.did, } }); @@ -96,11 +96,38 @@ export async function GET(request: Request, context: RouteContext) { website: user.website, movedTo: user.movedTo, isBot: user.isBot, - publicKey: user.publicKey, // RSA key for signing - chatPublicKey: user.chatPublicKey, // ECDH key for E2E chat + publicKey: user.publicKey, // Signing key did: user.did, // V2 Identity + dmPrivacy: user.dmPrivacy, }; + // Check if viewer can DM this user + let canReceiveDms = true; + if (user.isBot) { + canReceiveDms = false; + } else if (user.dmPrivacy === 'none') { + canReceiveDms = false; + } else if (user.dmPrivacy === 'following') { + canReceiveDms = false; // Default to false for 'following' + const session = await getSession(); + if (session?.user) { + if (session.user.id === user.id) { + canReceiveDms = true; // Can DM yourself + } else { + const isFollowingViewer = await db.query.follows.findFirst({ + where: and( + eq(follows.followerId, user.id), + eq(follows.followingId, session.user.id) + ) + }); + if (isFollowingViewer) { + canReceiveDms = true; + } + } + } + } + userResponse.canReceiveDms = canReceiveDms; + // If this is a bot, include owner info if (user.isBot && user.botOwnerId) { const owner = await db.query.users.findFirst({ diff --git a/src/app/settings/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx similarity index 99% rename from src/app/settings/bots/[id]/edit/page.tsx rename to src/app/bots/[id]/edit/page.tsx index 1bc75b4..0574079 100644 --- a/src/app/settings/bots/[id]/edit/page.tsx +++ b/src/app/bots/[id]/edit/page.tsx @@ -299,7 +299,7 @@ export default function EditBotPage() { } } - router.push(`/settings/bots/${botId}`); + router.push(`/bots/${botId}`); } catch (err) { console.error('Update bot error:', err); setError(err instanceof Error ? err.message : 'Failed to update bot'); diff --git a/src/app/settings/bots/[id]/page.tsx b/src/app/bots/[id]/page.tsx similarity index 97% rename from src/app/settings/bots/[id]/page.tsx rename to src/app/bots/[id]/page.tsx index 00df41d..6628c32 100644 --- a/src/app/settings/bots/[id]/page.tsx +++ b/src/app/bots/[id]/page.tsx @@ -76,7 +76,7 @@ export default function BotDetailPage() { const handleTriggerPost = async () => { setActionLoading(true); try { - const response = await fetch(`/api/bots/${botId}/post`, { + const response = await fetch(`/api/bots/${botId}/post`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), @@ -188,10 +188,10 @@ export default function BotDetailPage() { }); if (response.ok) { setShowAddSource(false); - setNewSource({ - type: 'rss', - url: '', - subreddit: '', + setNewSource({ + type: 'rss', + url: '', + subreddit: '', apiKey: '', braveQuery: '', braveFreshness: 'pw', @@ -252,7 +252,7 @@ export default function BotDetailPage() {

Bot not found

- + Back to Bots
@@ -260,8 +260,8 @@ export default function BotDetailPage() { ); } - const scheduleConfig = typeof bot.scheduleConfig === 'string' - ? JSON.parse(bot.scheduleConfig) + const scheduleConfig = typeof bot.scheduleConfig === 'string' + ? JSON.parse(bot.scheduleConfig) : bot.scheduleConfig || null; const personalityConfig = typeof bot.personalityConfig === 'string' ? JSON.parse(bot.personalityConfig) @@ -270,16 +270,16 @@ export default function BotDetailPage() { return (
- +

{bot.name}

{bot.autonomousMode && ( - @@ -407,8 +407,8 @@ export default function BotDetailPage() { Personality -
{ if (confirm(`Are you sure you want to delete ${bot.name}? This cannot be undone.`)) { fetch(`/api/bots/${botId}`, { method: 'DELETE' }) - .then(() => router.push('/settings/bots')) + .then(() => router.push('/bots')) .catch(() => showToast('Failed to delete bot', 'error')); } }} diff --git a/src/app/settings/bots/new/page.tsx b/src/app/bots/new/page.tsx similarity index 99% rename from src/app/settings/bots/new/page.tsx rename to src/app/bots/new/page.tsx index 4630fec..66a6d4f 100644 --- a/src/app/settings/bots/new/page.tsx +++ b/src/app/bots/new/page.tsx @@ -242,7 +242,7 @@ export default function NewBotPage() { }); } - router.push(`/settings/bots/${data.bot.id}`); + router.push(`/bots/${data.bot.id}`); } else { const data = await response.json(); console.error('Bot creation failed:', data); @@ -845,7 +845,7 @@ export default function NewBotPage() { return (
- +
diff --git a/src/app/bots/page.tsx b/src/app/bots/page.tsx new file mode 100644 index 0000000..9042ef5 --- /dev/null +++ b/src/app/bots/page.tsx @@ -0,0 +1,217 @@ +/** + * Bot Management Page + * + * Lists user's bots and provides creation interface. + * + * Requirements: 1.3 + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeftIcon } from '@/components/Icons'; +import { Bot, Plus, Sparkles } from 'lucide-react'; + +interface BotData { + id: string; + name: string; + handle: string; + bio: string; + avatarUrl: string | null; + isActive: boolean; + isSuspended: boolean; + autonomousMode: boolean; + lastPostAt: Date | null; + createdAt: Date; +} + +export default function BotsPage() { + const router = useRouter(); + const [bots, setBots] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchBots(); + }, []); + + const fetchBots = async () => { + try { + const response = await fetch('/api/bots'); + if (response.ok) { + const data = await response.json(); + setBots(data.bots || []); + } + } catch (error) { + console.error('Failed to fetch bots:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+ Loading... +
+
+ + ); + } + + return ( + <> +
+
+

+ + Bots +

+ + + Create + +
+
+ +
+ + {bots.length === 0 ? ( +
+ +

+ No bots yet +

+

+ Create your first bot to start automating posts and interactions +

+ + + Create Your First Bot + +
+ ) : ( +
+ {bots.map((bot) => ( +
router.push(`/bots/${bot.id}`)} + > +
+
+ e.stopPropagation()} + className="avatar" + style={{ + width: '48px', + height: '48px', + flexShrink: 0, + fontSize: '18px', + }} + > + {bot.avatarUrl ? ( + {bot.name} + ) : ( + bot.name.charAt(0).toUpperCase() + )} + +
+
+

{bot.name}

+ {bot.autonomousMode && ( + + + Auto + + )} +
+ e.stopPropagation()} + style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }} + > + @{bot.handle} + + {bot.bio && ( +

+ {bot.bio} +

+ )} +
+
+
+ {bot.isSuspended ? ( + + Suspended + + ) : bot.isActive ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + Last post: {bot.lastPostAt + ? new Date(bot.lastPostAt).toLocaleDateString() + : 'Never'} + + + Created: {new Date(bot.createdAt).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 4b61de4..86802ca 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from 'react'; import { useAuth } from '@/lib/contexts/AuthContext'; -import { useSodiumChat } from '@/lib/hooks/useSodiumChat'; +import { signedAPI } from '@/lib/api/signed-fetch'; import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { formatFullHandle } from '@/lib/utils/handle'; import { useRouter, useSearchParams } from 'next/navigation'; @@ -21,15 +21,14 @@ interface Conversation { unreadCount: number; } + interface Message { id: string; senderHandle: string; senderDisplayName?: string; senderAvatarUrl?: string; - senderDid?: string; // V2 needs DID - senderPublicKey?: string; // Legacy - encryptedContent: string; - decryptedContent?: string; + senderDid?: string; + content: string; isSentByMe: boolean; deliveredAt?: string; readAt?: string; @@ -39,8 +38,6 @@ interface Message { export default function ChatPage() { const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const router = useRouter(); - // Libsodium E2EE Hook - const { isReady, status, sendMessage, decryptMessage } = useSodiumChat(); const searchParams = useSearchParams(); const composeHandle = searchParams.get('compose'); @@ -49,20 +46,11 @@ export default function ChatPage() { const [selectedConversation, setSelectedConversation] = useState(null); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); - const [newChatHandle, setNewChatHandle] = useState(''); - const [showNewChat, setShowNewChat] = useState(false); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); - // Cache for decrypted messages to avoid re-decrypting on every poll - const decryptedCacheRef = useRef>(new Map()); - // Track which messages we've attempted to decrypt (even if they failed) - const attemptedDecryptionRef = useRef>(new Set()); - // Track the current conversation ID to prevent race conditions - const currentConversationIdRef = useRef(null); - // Legacy / V2 Hybrid State const [showDeleteModal, setShowDeleteModal] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); @@ -96,12 +84,14 @@ export default function ChatPage() { } }; - const loadConversations = async (isInitialLoad = false) => { - if (isInitialLoad) setLoading(true); + const loadConversations = async (isInitialLoad = true) => { try { + if (isInitialLoad) setLoading(true); const res = await fetch('/api/swarm/chat/conversations'); - const data = await res.json(); - setConversations(data.conversations || []); + if (res.ok) { + const data = await res.json(); + setConversations(data.conversations || []); + } } catch (e) { console.error("Failed to load conversations", e); } finally { @@ -114,81 +104,23 @@ export default function ChatPage() { const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`); const data = await res.json(); - const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => { - try { - // Check cache first - const cacheKey = `${msg.id}`; - const cached = decryptedCacheRef.current.get(cacheKey); - if (cached) { - return { ...msg, decryptedContent: cached }; - } - - // Check if already attempted - if (attemptedDecryptionRef.current.has(cacheKey)) { - const fallback = decryptedCacheRef.current.get(cacheKey) || '🔒 [Encrypted]'; - return { ...msg, decryptedContent: fallback }; - } - - // Mark as attempted - attemptedDecryptionRef.current.add(cacheKey); - - // Parse libsodium message format - if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) { - try { - const envelope = JSON.parse(msg.encryptedContent); - - // Libsodium format: {senderPublicKey, recipientDid, ciphertext, nonce} - if (envelope.senderPublicKey && envelope.ciphertext && envelope.nonce) { - // For decryption with crypto_box_open_easy: - // - We need the OTHER party's public key - // - We use OUR private key - - // If I sent this message, the "other party" is the recipient - // If I received this message, the "other party" is the sender - let otherPartyPublicKey = envelope.senderPublicKey; - - if (msg.isSentByMe && envelope.recipientDid) { - // I'm the sender, so I need the recipient's public key to decrypt my own message - try { - const keyRes = await fetch(`/api/chat/keys?did=${encodeURIComponent(envelope.recipientDid)}`, { cache: 'no-store' }); - if (keyRes.ok) { - const keyData = await keyRes.json(); - otherPartyPublicKey = keyData.publicKey; - } - } catch (e) { - console.error('[Chat UI] Failed to fetch recipient key:', e); - } - } else { - // Using sender public key from envelope - } - - const plaintext = await decryptMessage( - envelope.ciphertext, - envelope.nonce, - otherPartyPublicKey - ); - - decryptedCacheRef.current.set(cacheKey, plaintext); - return { ...msg, decryptedContent: plaintext }; - } - } catch (e) { - console.error('[Chat UI] Libsodium decryption failed:', e); - } - } - - // Fallback - const fallback = '🔒 [Encrypted - refresh page]'; - decryptedCacheRef.current.set(cacheKey, fallback); - return { ...msg, decryptedContent: fallback }; - } catch (err) { - console.error('[Chat UI] Message processing error:', err); - return { ...msg, decryptedContent: '[Error]' }; - } + const plainMessages = (data.messages || []).map((msg: any) => ({ + ...msg, + content: msg.content || '[Empty Message]' })); - setMessages(decrypted); - } catch (err) { - console.error('[Chat UI] Load messages error:', err); + // Only update if different + setMessages(prev => { + const prevIds = prev.map(m => m.id).join(','); + const newIds = plainMessages.map((m: any) => m.id).join(','); + if (prevIds === newIds && prev.length === plainMessages.length) return prev; + return plainMessages; + }); + + // Mark as read + markAsRead(conversationId); + } catch (e) { + console.error("Failed to load messages", e); } }; @@ -218,8 +150,18 @@ export default function ChatPage() { if (!did) throw new Error('User not found'); } - // Send using Signal Protocol - await sendMessage(did, newMessage, selectedConversation.participant2.handle); + if (!user.did) throw new Error('User DID missing'); + + + + // Send using Signed API + await signedAPI.sendChat( + did, + selectedConversation.participant2.handle, + newMessage, + user.did, + user.handle + ); setNewMessage(''); @@ -252,71 +194,6 @@ export default function ChatPage() { } }; - const startNewChat = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newChatHandle.trim()) return; - setSending(true); - try { - let cleanHandle = newChatHandle.replace(/^@/, ''); - - // If the handle includes a domain, check if it's our local domain - if (cleanHandle.includes('@')) { - const [handle, domain] = cleanHandle.split('@'); - const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || window.location.host; - - // If it's our local domain, strip it for the API call - if (domain === localDomain) { - cleanHandle = handle; - } - } - - const res = await fetch(`/api/users/${encodeURIComponent(cleanHandle)}`); - const data = await res.json(); - - if (!data.user?.did) { - alert('User not found or Olm encryption not enabled.'); - setSending(false); - return; - } - - // Check if existing conversation - const existing = conversations.find(c => - c.participant2.handle.toLowerCase() === data.user.handle.toLowerCase() - ); - - if (existing) { - setSelectedConversation(existing); - } else { - // Setup draft - const draftConv: Conversation = { - id: 'new', - participant2: { - handle: data.user.handle, - displayName: data.user.displayName || data.user.handle, - avatarUrl: data.user.avatarUrl, - did: data.user.did - }, - lastMessageAt: new Date().toISOString(), - lastMessagePreview: 'New Conversation', - unreadCount: 0 - }; - setSelectedConversation(draftConv); - } - - setShowNewChat(false); - setNewChatHandle(''); - } catch (e: any) { - console.error('[Chat UI] Start chat failed:', e); - if (e.message.includes('Recipient keys not found') || e.message.includes('Failed to fetch recipient keys')) { - alert('This user has not set up secure chat yet. They need to log in to enable end-to-end encryption.'); - } else { - alert('Failed to start chat: ' + e.message); - } - } finally { - setSending(false); - } - }; - const handleDeleteConversation = async (deleteFor: 'self' | 'both') => { if (!conversationToDelete) return; setIsDeleting(true); @@ -344,9 +221,10 @@ export default function ChatPage() { // EFFECTS (Now that functions are defined) // ============================================ + // Load conversations // Load conversations useEffect(() => { - if (user && isReady) { + if (user) { loadConversations(true); // Initial load with spinner // Poll for new conversations every 5 seconds (no spinner) @@ -356,11 +234,11 @@ export default function ChatPage() { return () => clearInterval(pollInterval); } - }, [user, isReady]); + }, [user]); // Handle Compose Intent useEffect(() => { - if (composeHandle && isReady && !selectedConversation && conversations.length >= 0) { + if (composeHandle && !selectedConversation && conversations.length >= 0) { // Check if we already have a conversation with this user const existing = conversations.find(c => c.participant2.handle.toLowerCase() === composeHandle.toLowerCase() @@ -377,6 +255,11 @@ export default function ChatPage() { const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`); const data = await res.json(); if (data.user) { + if (data.user.isBot || data.user.canReceiveDms === false) { + console.error('Cannot DM this account due to privacy settings'); + router.replace('/chat'); + return; + } const draftConv: Conversation = { id: 'new', participant2: { @@ -404,7 +287,7 @@ export default function ChatPage() { fetchUserAndInitDraft(); } } - }, [composeHandle, isReady, selectedConversation, conversations, loading, router]); + }, [composeHandle, selectedConversation, conversations, loading, router]); // Redirect if not logged in useEffect(() => { @@ -415,9 +298,8 @@ export default function ChatPage() { // Load messages when conversation is selected useEffect(() => { - if (selectedConversation && isReady) { - // Update current conversation ref - currentConversationIdRef.current = selectedConversation.id; + if (selectedConversation) { + // Clear messages immediately to prevent flash setMessages([]); @@ -435,7 +317,7 @@ export default function ChatPage() { // Poll for new messages every 3 seconds const pollInterval = setInterval(() => { // Only load if still the same conversation - if (currentConversationIdRef.current === selectedConversation.id && selectedConversation.id !== 'new') { + if (selectedConversation.id !== 'new') { loadMessages(selectedConversation.id); } }, 3000); @@ -443,11 +325,11 @@ export default function ChatPage() { return () => clearInterval(pollInterval); } else if (!selectedConversation) { // Clear messages when no conversation selected - currentConversationIdRef.current = null; + setMessages([]); setLoadingMessages(false); } - }, [selectedConversation, isReady]); + }, [selectedConversation]); // Auto-scroll to bottom of messages only if user was already at bottom useEffect(() => { @@ -471,10 +353,10 @@ export default function ChatPage() { if (!isIdentityUnlocked) { return (
- -

Identity Locked

+ +

Identity Required

- End-to-end encrypted chat requires your identity to be unlocked. Your private keys are needed to encrypt and decrypt messages. + Chat requires your identity to be unlocked. Your private keys are used to sign messages to prove they came from you.

-
- ); - } - // Loading State - if (!isReady || status === 'initializing') { - return ( -
- -
- ); - } // Prevent flash of list view while processing compose intent if (composeHandle && !selectedConversation) { @@ -530,15 +386,24 @@ export default function ChatPage() { return (
{/* Header */} -
+
-
+
{selectedConversation.participant2.avatarUrl ? ( ) : ( @@ -546,19 +411,19 @@ export default function ChatPage() { )}
-
{selectedConversation.participant2.displayName}
-
+
{selectedConversation.participant2.displayName}
+
{formatFullHandle(selectedConversation.participant2.handle)}
-
+
{/* Messages */}
- {msg.decryptedContent || msg.encryptedContent} + {msg.content}
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} @@ -685,30 +550,24 @@ export default function ChatPage() { // LIST VIEW return ( <> -
-
-

Messages

- -
+
+
+
+

Chat

+
+
- {showNewChat ? ( -
- setNewChatHandle(e.target.value)} - autoFocus - /> -
- - -
-
- ) : ( +
setSearchQuery(e.target.value)} />
- )} +
{loading ? ( diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index b9b54f8..98f567b 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -287,9 +287,22 @@ export default function ExplorePage() { return (
-
-

Explore

-
+
+
+

Explore

+
+
+ +
+ setQuery(e.target.value)} /> -
+
+
+

Privacy & Safety

+

+ Message and social privacy preferences +

+
+
+ +
+
+
+ +

Direct Messages

+
+ +

+ Control who can send you direct messages on Synapsis. +

+ +
+ + + + + +
+
+ + {status && ( +
+ {status.message} +
+ )} +
+
+ ); +} diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 8c50459..2a76dd6 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -504,8 +504,8 @@ export default function ProfilePage() { {isFollowing ? 'Following' : 'Follow'} )} - {/* Message Button (V2 Chat) */} - {user.did && ( + {/* Message Button (V2 Chat) - Respect privacy settings */} + {user.did && !user.isBot && (user as any).canReceiveDms !== false && ( diff --git a/src/components/IdentityUnlockPrompt.tsx b/src/components/IdentityUnlockPrompt.tsx index 1c737b7..bd759b8 100644 --- a/src/components/IdentityUnlockPrompt.tsx +++ b/src/components/IdentityUnlockPrompt.tsx @@ -66,47 +66,72 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp left: 0, right: 0, bottom: 0, - background: 'rgba(0, 0, 0, 0.5)', + background: 'rgba(0, 0, 0, 0.8)', display: 'flex', - alignItems: 'center', + alignItems: 'flex-end', justifyContent: 'center', zIndex: 99999, - padding: '16px' + padding: 0 }} onClick={handleCancel} >
e.stopPropagation()} > + + + {/* Drag indicator */} +
+ {/* Header */} -
+
- +
-

- Unlock Identity +

+ Identity Required

+

+ Enter your password to unlock your identity +

- {/* Description */} -

- Enter your password to unlock your cryptographic identity and perform actions. -

- {/* Form */}
@@ -128,19 +153,19 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp value={password} onChange={(e) => { setPassword(e.target.value); - setError(null); // Clear error when user types + setError(null); }} disabled={isUnlocking} placeholder="Enter your password" autoFocus style={{ width: '100%', - padding: '10px 12px', - borderRadius: '8px', - border: error ? '1px solid var(--error)' : '1px solid var(--border)', + padding: '14px 16px', + borderRadius: '12px', + border: error ? '2px solid var(--error)' : '2px solid var(--border)', background: 'var(--background)', color: 'var(--foreground)', - fontSize: '14px', + fontSize: '16px', outline: 'none', transition: 'border-color 0.2s' }} @@ -163,39 +188,47 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp display: 'flex', alignItems: 'center', gap: '8px', - padding: '10px 12px', - borderRadius: '8px', + padding: '12px 14px', + borderRadius: '10px', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', fontSize: '14px', marginBottom: '16px' }}> - + {error}
)} {/* Buttons */} -
+
@@ -212,13 +252,14 @@ export function IdentityUnlockPrompt({ onUnlock, onCancel }: IdentityUnlockPromp {/* Info Note */}

- You can browse without unlocking, but you'll need to unlock to like, post, or follow. + Your password never leaves this device

diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index 5cbe72e..749e890 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -51,7 +51,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { } return ( -
+
{children} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8343632..1959fa3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -95,87 +95,93 @@ export function Sidebar() { {user && ( -
+
{user.avatarUrl ? ( diff --git a/src/db/schema.ts b/src/db/schema.ts index 9134494..96ba134 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -43,9 +43,6 @@ export const users = pgTable('users', { headerUrl: text('header_url'), privateKeyEncrypted: text('private_key_encrypted'), // For cryptographic signing publicKey: text('public_key').notNull(), - // E2E Chat keys (ECDH) - separate from signing keys - chatPublicKey: text('chat_public_key'), // ECDH public key for chat encryption - chatPrivateKeyEncrypted: text('chat_private_key_encrypted'), // Encrypted with user's password nodeId: uuid('node_id').references(() => nodes.id), // Bot-related fields isBot: boolean('is_bot').default(false).notNull(), @@ -69,6 +66,7 @@ export const users = pgTable('users', { followingCount: integer('following_count').default(0).notNull(), postsCount: integer('posts_count').default(0).notNull(), website: text('website'), + dmPrivacy: text('dm_privacy').default('everyone').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [ @@ -927,12 +925,8 @@ export const chatMessages = pgTable('chat_messages', { senderNodeDomain: text('sender_node_domain'), // null if local senderDid: text('sender_did'), // DID for Signal Protocol - // Message content (encrypted for recipient with their public key) - encryptedContent: text('encrypted_content').notNull(), - // Sender's copy (encrypted with sender's public key so they can read their own messages) - senderEncryptedContent: text('sender_encrypted_content'), - // Sender's ECDH public key (for E2E decryption by recipient) - senderChatPublicKey: text('sender_chat_public_key'), + // Message content (plain text for verified chat) + content: text('content'), // Swarm sync info swarmMessageId: text('swarm_message_id').unique(), // Format: swarm:domain:uuid @@ -956,108 +950,7 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ }), })); -/** - * V2.1 E2EE: Device Bundles - * Stores the identity and signed prekeys for each user device. - * Verified by the root ECDSA identity key (signature). - */ -export const chatDeviceBundles = pgTable('chat_device_bundles', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - did: text('did').notNull(), - // The device identifier (UUID generated by client) - deviceId: text('device_id').notNull(), - - // Signal Protocol fields - registrationId: integer('registration_id'), - - // X25519 Identity Key (Base64) - identityKey: text('identity_key').notNull(), - - // Signed PreKey (JSON: { id, key, sig }) - signedPreKey: text('signed_pre_key').notNull(), - - // Kyber PreKey for post-quantum security (JSON: { id, key, sig }) - kyberPreKey: text('kyber_pre_key'), - - // One-Time Keys (JSON array of { id, key }) - cached/uploaded batch - // Note: Individual keys are usually stored in a separate table for atomic consumption, - // but initial upload can be here or we strictly use chat_one_time_keys. - // We will use the separate table for consumption tracking. - - // The ECDSA signature covering (did, deviceId, identityKey, signedPreKey) - // Signed by the user's Root Identity Key (P-256) - signature: text('signature').notNull(), - - lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), -}, (table) => [ - index('chat_bundles_user_idx').on(table.userId), - index('chat_bundles_did_idx').on(table.did), - // compound index for fast lookup of a specific device - uniqueIndex('chat_bundles_device_unique').on(table.userId, table.deviceId), -]); - -export const chatDeviceBundlesRelations = relations(chatDeviceBundles, ({ one, many }) => ({ - user: one(users, { - fields: [chatDeviceBundles.userId], - references: [users.id], - }), - oneTimeKeys: many(chatOneTimeKeys), -})); - -/** - * V2.1 E2EE: One-Time Prekeys - * Pool of disposable keys for X3DH. - */ -export const chatOneTimeKeys = pgTable('chat_one_time_keys', { - id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), - bundleId: uuid('bundle_id').notNull().references(() => chatDeviceBundles.id, { onDelete: 'cascade' }), - - keyId: integer('key_id').notNull(), - publicKey: text('public_key').notNull(), // X25519 Base64 - - createdAt: timestamp('created_at').defaultNow().notNull(), -}, (table) => [ - index('chat_otk_bundle_idx').on(table.bundleId), - // Ensure (bundleId, keyId) is unique - uniqueIndex('chat_otk_unique').on(table.bundleId, table.keyId), -]); - -export const chatOneTimeKeysRelations = relations(chatOneTimeKeys, ({ one }) => ({ - bundle: one(chatDeviceBundles, { - fields: [chatOneTimeKeys.bundleId], - references: [chatDeviceBundles.id], - }), -})); - -/** - * V2.1 E2EE: Chat Inbox - * Stores encrypted envelopes waiting for delivery to a specific device. - */ -export const chatInbox = pgTable('chat_inbox', { - id: uuid('id').primaryKey().defaultRandom(), - - // Recipient info - recipientDid: text('recipient_did').notNull(), - recipientDeviceId: text('recipient_device_id'), // Null means "all devices" or "not yet routed" - - // Sender info - senderDid: text('sender_did').notNull(), - - // The Envelope (SignedAction format) - // Contains: { action: "chat.deliver", did, handle, ts, nonce, data: { ciphertext, header... }, sig } - envelope: text('envelope_json').notNull(), - - isRead: boolean('is_read').default(false).notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - expiresAt: timestamp('expires_at').notNull(), // TTL (e.g. 30 days) -}, (table) => [ - index('chat_inbox_recipient_idx').on(table.recipientDid, table.recipientDeviceId), - index('chat_inbox_created_idx').on(table.createdAt), -]); /** * Typing indicators for real-time chat UX. diff --git a/src/lib/api/signed-fetch.ts b/src/lib/api/signed-fetch.ts index 4b08de5..a4d30bc 100644 --- a/src/lib/api/signed-fetch.ts +++ b/src/lib/api/signed-fetch.ts @@ -210,4 +210,16 @@ export const signedAPI = { userHandle ); }, + /** + * Send a chat message + */ + async sendChat(recipientDid: string, recipientHandle: string, content: string, userDid: string, userHandle: string) { + return signedFetch( + '/api/chat/send', + 'chat', + { recipientDid, recipientHandle, content }, + userDid, + userHandle + ); + }, }; diff --git a/src/lib/auth/verify-signature.ts b/src/lib/auth/verify-signature.ts index 0785f93..8f484ae 100644 --- a/src/lib/auth/verify-signature.ts +++ b/src/lib/auth/verify-signature.ts @@ -29,7 +29,39 @@ export interface SignedAction { } /** - * Verify a signed user action + * Verify a signed action against a specific public key + */ +export async function verifyActionSignature(signedAction: SignedAction, publicKeyStr: string): Promise { + try { + const { sig, ...payload } = signedAction; + const canonicalString = canonicalize(payload); + const encoder = new TextEncoder(); + const dataBytes = encoder.encode(canonicalString); + + // Convert signature from Base64Url to buffer + const sigBase64 = base64UrlToBase64(sig); + const sigBuffer = Buffer.from(sigBase64, 'base64'); + + // Import public key (stored as SPKI Base64) + const publicKey = await importPublicKey(publicKeyStr); + + return await cryptoSubtle.verify( + { + name: 'ECDSA', + hash: { name: 'SHA-256' }, + }, + publicKey, + sigBuffer, + dataBytes + ); + } catch (error) { + console.error('[Verify] Crypto exception:', error); + return false; + } +} + +/** + * Verify a signed user action (looks up user in DB) * * @param signedAction - The signed action payload * @returns The user if signature is valid and not replayed @@ -48,6 +80,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{ // 1. FRESHNESS CHECK (Fail fast before DB/Crypto) const now = Date.now(); const diff = Math.abs(now - payload.ts); + // Allow 5 minutes clock skew const fiveMinutesMs = 5 * 60 * 1000; if (diff > fiveMinutesMs) { @@ -60,8 +93,6 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{ }); if (!user) { - // If federation, we might need to look up in remote_identity_cache here. - // For now, assume local user or user must exist in users table (synced). return { valid: false, error: 'User not found' }; } @@ -70,60 +101,34 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{ } // 3. CRYPTOGRAPHIC VERIFICATION - try { - const canonicalString = canonicalize(payload); - const encoder = new TextEncoder(); - const dataBytes = encoder.encode(canonicalString); + const isValid = await verifyActionSignature(signedAction, user.publicKey); - // Convert signature from Base64Url to buffer - const sigBase64 = base64UrlToBase64(sig); - const sigBuffer = Buffer.from(sigBase64, 'base64'); - - // Import public key (stored as SPKI Base64 in DB) - const publicKey = await importPublicKey(user.publicKey); - - const isValid = await cryptoSubtle.verify( - { - name: 'ECDSA', - hash: { name: 'SHA-256' }, - }, - publicKey, - sigBuffer, - dataBytes - ); - - if (!isValid) { - return { valid: false, error: 'INVALID_SIGNATURE' }; - } - - // 4. ACTION ID HASH COMPUTATION - // SHA-256(canonicalPayload) - // We use the same canonical string we just verified. - const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex'); - - // 5. REPLAY PROTECTION (DB) - try { - await db.insert(signedActionDedupe).values({ - actionId: actionIdHash, - did: payload.did, - nonce: payload.nonce, - ts: payload.ts, - }); - } catch (err: any) { - // Check for unique constraint violation (duplicate key) - if (err.code === '23505') { // Postgres unique_violation code - return { valid: false, error: 'REPLAYED_NONCE' }; - } - console.error('[Verify] Dedupe error:', err); - throw err; // Internal error - } - - return { valid: true, user }; - - } catch (error) { - console.error('[Verify] Verification exception:', error); - return { valid: false, error: 'VERIFICATION_ERROR' }; + if (!isValid) { + return { valid: false, error: 'INVALID_SIGNATURE' }; } + + // 4. ACTION ID HASH COMPUTATION + const canonicalString = canonicalize(payload); + const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex'); + + // 5. REPLAY PROTECTION (DB) + try { + await db.insert(signedActionDedupe).values({ + actionId: actionIdHash, + did: payload.did, + nonce: payload.nonce, + ts: payload.ts, + }); + } catch (err: any) { + // Check for unique constraint violation (duplicate key) + if (err.code === '23505') { // Postgres unique_violation code + return { valid: false, error: 'REPLAYED_NONCE' }; + } + console.error('[Verify] Dedupe error:', err); + throw err; // Internal error + } + + return { valid: true, user }; } /** diff --git a/src/lib/contexts/AuthContext.tsx b/src/lib/contexts/AuthContext.tsx index e6bb481..60f4ac5 100644 --- a/src/lib/contexts/AuthContext.tsx +++ b/src/lib/contexts/AuthContext.tsx @@ -80,15 +80,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } await unlockIdentityHook( - targetUser.privateKeyEncrypted, + targetUser.privateKeyEncrypted, password, targetUser.did, targetUser.handle, targetUser.publicKey ); - // Signal Protocol will auto-initialize when the chat page is opened - + setShowUnlockPrompt(false); // Close prompt on success }; diff --git a/src/lib/crypto/client-crypto.ts b/src/lib/crypto/client-crypto.ts deleted file mode 100644 index a098120..0000000 --- a/src/lib/crypto/client-crypto.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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 { - 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 { - 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 { - 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 { - 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 { - try { - const myPrivateKey = await importPrivateKey(myPrivateKeyBase64); - const theirPublicKey = await importPublicKey(theirPublicKeyBase64); - const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); - - const combined = base64ToBuffer(encryptedMessage); - - if (combined.byteLength < 12) { - throw new Error('Message too short'); - } - - 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); - } catch (error) { - console.error('Decryption failed:', error); - return '[Message cannot be 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 { - // Gracefull handle null/undefined - if (!base64) return new ArrayBuffer(0); - - // Check for JSON (legacy format) - if (base64.trim().startsWith('{')) { - console.warn('[base64ToBuffer] Detected JSON instead of Base64, returning empty buffer'); - throw new Error('Invalid message format: JSON detected'); - } - - // Clean the string: - // 1. Remove newlines/tabs (formatting) - // 2. Replace spaces with '+' (common URL decoding error where + becomes space) - // 3. Handle URL-safe chars (- -> +, _ -> /) - const cleaned = base64.replace(/[\n\r\t]/g, '') - .replace(/ /g, '+') - .replace(/-/g, '+') - .replace(/_/g, '/'); - - try { - const binary = atob(cleaned); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; - } catch (e) { - console.error('[base64ToBuffer] Failed to decode base64:', e); - throw new Error(`Failed to decode base64: ${e instanceof Error ? e.message : String(e)}`); - } -} diff --git a/src/lib/crypto/sodium-chat.ts b/src/lib/crypto/sodium-chat.ts deleted file mode 100644 index 390c16e..0000000 --- a/src/lib/crypto/sodium-chat.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Libsodium E2EE Chat Implementation - * Keys stored encrypted in IndexedDB using storage key from identity unlock - */ - -import sodium from 'libsodium-wrappers-sumo'; - -let sodiumReady = false; - -const DB_NAME = 'synapsis_chat'; -const DB_VERSION = 1; -const STORE_NAME = 'chat_keys'; - -/** - * Initialize libsodium (must be called before any crypto operations) - */ -export async function initSodium() { - if (sodiumReady) return; - await sodium.ready; - sodiumReady = true; -} - -/** - * Open IndexedDB - */ -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME); - } - }; - }); -} - -/** - * Generate a new key pair for chat encryption - */ -export async function generateChatKeyPair(): Promise<{ - publicKey: string; // base64 - privateKey: string; // base64 -}> { - await initSodium(); - - const keyPair = sodium.crypto_box_keypair(); - - return { - publicKey: sodium.to_base64(keyPair.publicKey), - privateKey: sodium.to_base64(keyPair.privateKey), - }; -} - -// Helper to robustly decode Base64 regardless of variant (Original vs URLSafe) -function tryDecodeBase64(str: string): Uint8Array { - // Use a Set to ensure uniqueness and order, explicitly allowing undefined - const variants = new Set(); - - // Prefer standard/known variants first - if (sodium.base64_variants) { - if (sodium.base64_variants.ORIGINAL !== undefined) variants.add(sodium.base64_variants.ORIGINAL); - if (sodium.base64_variants.URLSAFE !== undefined) variants.add(sodium.base64_variants.URLSAFE); - if (sodium.base64_variants.ORIGINAL_NO_PADDING !== undefined) variants.add(sodium.base64_variants.ORIGINAL_NO_PADDING); - if (sodium.base64_variants.URLSAFE_NO_PADDING !== undefined) variants.add(sodium.base64_variants.URLSAFE_NO_PADDING); - } - - // Always add default (undefined) as fallback - variants.add(undefined); - - let lastError; - for (const v of variants) { - try { - return v !== undefined - ? sodium.from_base64(str, v) - : sodium.from_base64(str); - } catch (e) { - lastError = e; - } - } - throw lastError || new Error('Failed to decode Base64 with any variant'); -} - -/** - * Encrypt a message for a recipient - */ -export async function encryptMessage( - message: string, - recipientPublicKey: string, // base64 - senderPrivateKey: string // base64 -): Promise<{ - ciphertext: string; // base64 - nonce: string; // base64 -}> { - await initSodium(); - - try { - const messageBytes = sodium.from_string(message); - - // keys may be dirty or differ in variant - const cleanRecipientKey = recipientPublicKey.trim(); - const cleanSenderKey = senderPrivateKey.trim(); - - // Robust decode for both keys independently - // This solves the issue where Local Key is URLSAFE but Remote Key is ORIGINAL - const recipientPubKey = tryDecodeBase64(cleanRecipientKey); - const senderPrivKey = tryDecodeBase64(cleanSenderKey); - - // Generate random nonce - const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES); - - // Encrypt - const ciphertext = sodium.crypto_box_easy( - messageBytes, - nonce, - recipientPubKey, - senderPrivKey - ); - - return { - ciphertext: sodium.to_base64(ciphertext), - nonce: sodium.to_base64(nonce), - }; - } catch (err) { - console.error('[Sodium-Chat] Encryption failed:', err); - console.error('Keys Debug:', { - recipientLen: recipientPublicKey.length, - senderLen: senderPrivateKey.length, - recipientStart: recipientPublicKey.substring(0, 5) - }); - throw err; - } -} - -/** - * Decrypt a message from a sender - */ -export async function decryptMessage( - ciphertext: string, // base64 - nonce: string, // base64 - senderPublicKey: string, // base64 - recipientPrivateKey: string // base64 -): Promise { - await initSodium(); - - const ciphertextBytes = tryDecodeBase64(ciphertext); - const nonceBytes = tryDecodeBase64(nonce); - const senderPubKey = tryDecodeBase64(senderPublicKey); - const recipientPrivKey = tryDecodeBase64(recipientPrivateKey); - - // Decrypt - const decrypted = sodium.crypto_box_open_easy( - ciphertextBytes, - nonceBytes, - senderPubKey, - recipientPrivKey - ); - - return sodium.to_string(decrypted); -} - -/** - * Store keys in IndexedDB (encrypted with storage key from memory) - */ -export async function storeKeys( - userId: string, - publicKey: string, - privateKey: string, - storageKey: Uint8Array -): Promise { - await initSodium(); - - // Generate random nonce - const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); - - // Encrypt private key with storage key - const privateKeyBytes = sodium.from_string(privateKey); - const ciphertext = sodium.crypto_secretbox_easy(privateKeyBytes, nonce, storageKey); - - // Combine nonce + ciphertext - const combined = new Uint8Array(nonce.length + ciphertext.length); - combined.set(nonce, 0); - combined.set(ciphertext, nonce.length); - - // Store in IndexedDB - const db = await openDB(); - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - - await new Promise((resolve, reject) => { - const request = store.put({ - publicKey, - encryptedPrivateKey: sodium.to_base64(combined) - }, userId); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - - db.close(); -} - -/** - * Retrieve keys from IndexedDB (decrypt with storage key from memory) - */ -export async function getStoredKeys( - userId: string, - storageKey: Uint8Array -): Promise<{ publicKey: string; privateKey: string } | null> { - await initSodium(); - - try { - const db = await openDB(); - const tx = db.transaction(STORE_NAME, 'readonly'); - const store = tx.objectStore(STORE_NAME); - - const data = await new Promise((resolve, reject) => { - const request = store.get(userId); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - - db.close(); - - if (!data) { - console.log('[Sodium] No stored keys found in IndexedDB for user:', userId); - return null; - } - - console.log('[Sodium] Found stored keys in IndexedDB, attempting to decrypt...'); - - const { publicKey, encryptedPrivateKey } = data; - - // Extract nonce and ciphertext - const combined = sodium.from_base64(encryptedPrivateKey); - const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES); - const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES); - - // Decrypt with storage key - const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, storageKey); - let privateKey = sodium.to_string(decrypted); - - // Validate that the decrypted key is actually valid Base64 - try { - tryDecodeBase64(privateKey); - } catch (e) { - console.warn('[Sodium] Private key appears invalid, attempting repair...'); - - // Attempt 1: Trim whitespace - let repaired = privateKey.trim(); - - // Attempt 2: Remove quotes (common JSON artifact) - repaired = repaired.replace(/['"]/g, ''); - - // Attempt 3: Remove newlines - repaired = repaired.replace(/[\n\r]/g, ''); - - try { - tryDecodeBase64(repaired); - console.log('[Sodium] Private key REPAIRED successfully!'); - privateKey = repaired; - } catch (finalErr) { - console.error('[Sodium] Decrypted private key is IRREPARABLE! Key store corrupted.'); - // We have to return null here as last resort, but we tried everything. - // Log the length/characteristics to help debug if this happens - console.error('Bad Key CharCodes:', privateKey.split('').map(c => c.charCodeAt(0)).slice(0, 10)); - return null; - } - } - - console.log('[Sodium] Successfully decrypted stored keys'); - return { publicKey, privateKey }; - } catch (error) { - console.error('[Sodium] Failed to decrypt stored keys - storage key mismatch?', error); - return null; - } -} - -/** - * Clear stored keys from IndexedDB - */ -export async function clearStoredKeys(userId: string): Promise { - try { - const db = await openDB(); - const tx = db.transaction(STORE_NAME, 'readwrite'); - const store = tx.objectStore(STORE_NAME); - - await new Promise((resolve, reject) => { - const request = store.delete(userId); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - - db.close(); - } catch (error) { - console.error('[Sodium] Failed to clear keys:', error); - } -} diff --git a/src/lib/crypto/user-signing.ts b/src/lib/crypto/user-signing.ts index 67f70fe..2f3e052 100644 --- a/src/lib/crypto/user-signing.ts +++ b/src/lib/crypto/user-signing.ts @@ -27,7 +27,7 @@ class InMemoryKeyStore implements KeyStore { private static instance: InMemoryKeyStore; private privateKey: CryptoKey | null = null; private identity: { did: string; handle: string; publicKey: string } | null = null; - private storageKey: Uint8Array | null = null; // For encrypting chat keys + private constructor() { } @@ -57,18 +57,11 @@ class InMemoryKeyStore implements KeyStore { return this.identity; } - setStorageKey(key: Uint8Array): void { - this.storageKey = key; - } - getStorageKey(): Uint8Array | null { - return this.storageKey; - } clear(): void { this.privateKey = null; this.identity = null; - this.storageKey = null; } } diff --git a/src/lib/hooks/useSodiumChat.ts b/src/lib/hooks/useSodiumChat.ts deleted file mode 100644 index 63cf649..0000000 --- a/src/lib/hooks/useSodiumChat.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * React Hook for Libsodium E2EE Chat - */ - -'use client'; - -import { useState, useCallback, useEffect, useRef } from 'react'; -import { useAuth } from '@/lib/contexts/AuthContext'; -import { keyStore } from '@/lib/crypto/user-signing'; -import * as SodiumChat from '@/lib/crypto/sodium-chat'; - -export function useSodiumChat() { - const { user, isIdentityUnlocked } = useAuth(); - const [isReady, setIsReady] = useState(false); - const [status, setStatus] = useState('idle'); - const keysRef = useRef<{ publicKey: string; privateKey: string } | null>(null); - - // Initialize and load/generate keys - useEffect(() => { - if (!user?.id || !isIdentityUnlocked) return; - - const init = async () => { - try { - setStatus('initializing'); - - await SodiumChat.initSodium(); - - // Get storage key from memory - const storageKey = keyStore.getStorageKey(); - if (!storageKey) { - throw new Error('Storage key not available - identity must be unlocked first'); - } - - // Try to load existing keys (encrypted in IndexedDB) - let keys = await SodiumChat.getStoredKeys(user.id, storageKey); - - if (!keys) { - // Generate new keys - console.log('[Sodium] Generating new key pair...'); - keys = await SodiumChat.generateChatKeyPair(); - await SodiumChat.storeKeys(user.id, keys.publicKey, keys.privateKey, storageKey); - - // Publish public key to server - console.log('[Sodium] Publishing public key to server...'); - const response = await fetch('/api/chat/keys', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicKey: keys.publicKey }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - console.error('[Sodium] Failed to publish key:', errorData); - throw new Error(`Failed to publish key: ${errorData.error || response.statusText}`); - } - - const result = await response.json(); - console.log('[Sodium] Keys generated and published successfully:', result); - } else { - console.log('[Sodium] Loaded existing keys from IndexedDB'); - - // Verify key exists on server - console.log('[Sodium] Verifying key on server...'); - if (!user.did) { - throw new Error('User DID not available'); - } - const checkResponse = await fetch(`/api/chat/keys?did=${encodeURIComponent(user.did)}`, { cache: 'no-store' }); - - let shouldPublish = false; - - if (!checkResponse.ok) { - console.log('[Sodium] Key not found on server, re-publishing...'); - shouldPublish = true; - } else { - // Check if the key on server MATCHES our local key - const serverData = await checkResponse.json(); - if (serverData.publicKey !== keys.publicKey) { - console.warn('[Sodium] Server key mismatch! Re-publishing local key...'); - shouldPublish = true; - } else { - console.log('[Sodium] Key verified on server'); - } - } - - if (shouldPublish) { - // Re-publish the key - const response = await fetch('/api/chat/keys', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicKey: keys.publicKey }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - console.error('[Sodium] Failed to re-publish key:', errorData); - throw new Error(`Failed to re-publish key: ${errorData.error || response.statusText}`); - } - - console.log('[Sodium] Key re-published successfully'); - } - } - - keysRef.current = keys; - setIsReady(true); - setStatus('ready'); - } catch (error) { - console.error('[Sodium] Initialization failed:', error); - setStatus('error'); - } - }; - - init(); - }, [user?.id, user?.did, isIdentityUnlocked]); - - const sendMessage = useCallback(async ( - recipientDid: string, - message: string, - recipientHandle?: string - ): Promise => { - if (!keysRef.current || !isReady || !user?.id) { - throw new Error('Sodium not ready'); - } - - try { - // Fetch recipient's public key - console.log('[Sodium] Fetching recipient public key for:', recipientDid); - let response = await fetch(`/api/chat/keys?did=${encodeURIComponent(recipientDid)}`); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - console.error('[Sodium] Failed to fetch recipient keys:', errorData); - throw new Error(`Failed to fetch recipient keys: ${errorData.error || response.statusText}`); - } - - const { publicKey: recipientPublicKey } = await response.json(); - console.log('[Sodium] Got recipient public key:', recipientPublicKey ? 'YES' : 'NO'); - - if (!recipientPublicKey) { - throw new Error('Recipient has no public key'); - } - - // Encrypt message - console.log('[Sodium] Encrypting message...'); - const encrypted = await SodiumChat.encryptMessage( - message, - recipientPublicKey, - keysRef.current.privateKey - ); - - // Send to server - console.log('[Sodium] Sending encrypted message to server...'); - response = await fetch('/api/chat/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientDid, - senderPublicKey: keysRef.current.publicKey, - ciphertext: encrypted.ciphertext, - nonce: encrypted.nonce, - recipientHandle, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: response.statusText })); - console.error('[Sodium] Failed to send message:', errorData); - throw new Error(`Failed to send message: ${errorData.error || response.statusText}`); - } - - console.log('[Sodium] Message sent successfully'); - } catch (error) { - console.error('[Sodium] Send failed:', error); - throw error; - } - }, [isReady, user?.id]); - - const decryptMessage = useCallback(async ( - ciphertext: string, - nonce: string, - senderPublicKey: string - ): Promise => { - if (!keysRef.current || !isReady) { - throw new Error('Sodium not ready'); - } - - try { - const plaintext = await SodiumChat.decryptMessage( - ciphertext, - nonce, - senderPublicKey, - keysRef.current.privateKey - ); - - return plaintext; - } catch (error) { - console.error('[Sodium] Decryption failed:', error); - throw error; - } - }, [isReady]); - - return { - isReady, - status, - sendMessage, - decryptMessage, - }; -} diff --git a/src/lib/hooks/useUserIdentity.ts b/src/lib/hooks/useUserIdentity.ts index 79a5f8e..744023f 100644 --- a/src/lib/hooks/useUserIdentity.ts +++ b/src/lib/hooks/useUserIdentity.ts @@ -92,7 +92,7 @@ export function useUserIdentity() { const unlockIdentity = async (privateKeyEncrypted: string, password: string, userDid?: string, userHandle?: string, userPublicKey?: string) => { try { console.log('[Identity] Unlocking with DID:', userDid, 'Handle:', userHandle); - + // Set identity first if provided (needed for storage key derivation) if (userDid && userHandle && userPublicKey) { keyStore.setIdentity({ @@ -130,43 +130,12 @@ export function useUserIdentity() { keyStore.setPrivateKey(cryptoKey); console.log('[Identity] Private key stored in memory'); - // 4. Derive and store storage key for chat encryption - // Use libsodium's pwhash to derive a storage key from the password - const sodiumModule = await import('libsodium-wrappers-sumo'); - await sodiumModule.default.ready; - const sodium = sodiumModule.default; - - // Use a fixed salt derived from the user's identity to ensure consistency - const identity = keyStore.getIdentity(); - console.log('[Identity] Retrieved identity from keyStore:', identity); - - if (identity) { - const saltString = `synapsis-chat-storage-${identity.did}`; - // Generate a fixed-length salt from the DID - // Hash to 32 bytes, then take first 16 bytes for salt - const fullHash = sodium.crypto_generichash(32, saltString, null); - const salt = fullHash.slice(0, sodium.crypto_pwhash_SALTBYTES); - - console.log('[Identity] Deriving storage key...'); - const storageKey = sodium.crypto_pwhash( - 32, // 32 bytes for secretbox - password, - salt, - sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, - sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, - sodium.crypto_pwhash_ALG_DEFAULT - ); - - keyStore.setStorageKey(storageKey); - console.log('[Identity] Storage key derived and stored'); - } else { - console.error('[Identity] No identity in keyStore - cannot derive storage key'); - } - - // 5. Update State + // 4. Update State setIdentity(prev => prev ? { ...prev, isUnlocked: true } : null); // We need the other data... setIsUnlocked(true); + + // If we didn't have identity wrapper set yet, we might need it. // Usually initializeIdentity handles both. diff --git a/src/lib/swarm/chat-crypto.ts b/src/lib/swarm/chat-crypto.ts deleted file mode 100644 index bffae38..0000000 --- a/src/lib/swarm/chat-crypto.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Swarm Chat Cryptography - * - * End-to-end encryption for chat messages using hybrid encryption: - * - AES-256-GCM for message encryption (fast, no size limit) - * - RSA-OAEP for encrypting the AES key (secure key exchange) - */ - -import crypto from 'crypto'; - -interface EncryptedPayload { - encryptedKey: string; // RSA-encrypted AES key (base64) - iv: string; // AES initialization vector (base64) - ciphertext: string; // AES-encrypted message (base64) - authTag: string; // GCM authentication tag (base64) -} - -/** - * Encrypt a message using hybrid encryption (AES + RSA) - */ -export function encryptMessage(message: string, recipientPublicKey: string): string { - try { - // Generate a random AES-256 key - const aesKey = crypto.randomBytes(32); - - // Generate a random IV for AES-GCM - const iv = crypto.randomBytes(12); - - // Encrypt the message with AES-256-GCM - const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv); - const encrypted = Buffer.concat([ - cipher.update(message, 'utf8'), - cipher.final() - ]); - const authTag = cipher.getAuthTag(); - - // Encrypt the AES key with RSA-OAEP - const encryptedKey = crypto.publicEncrypt( - { - key: recipientPublicKey, - padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: 'sha256', - }, - aesKey - ); - - // Package everything together - const payload: EncryptedPayload = { - encryptedKey: encryptedKey.toString('base64'), - iv: iv.toString('base64'), - ciphertext: encrypted.toString('base64'), - authTag: authTag.toString('base64'), - }; - - return JSON.stringify(payload); - } catch (error) { - console.error('Failed to encrypt message:', error); - throw new Error('Encryption failed'); - } -} - -/** - * Decrypt a message using hybrid encryption (AES + RSA) - */ -export function decryptMessage(encryptedMessage: string, privateKey: string): string { - try { - // Parse the encrypted payload - const payload: EncryptedPayload = JSON.parse(encryptedMessage); - - // Decrypt the AES key with RSA - const aesKey = crypto.privateDecrypt( - { - key: privateKey, - padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: 'sha256', - }, - Buffer.from(payload.encryptedKey, 'base64') - ); - - // Decrypt the message with AES-256-GCM - const decipher = crypto.createDecipheriv( - 'aes-256-gcm', - aesKey, - Buffer.from(payload.iv, 'base64') - ); - decipher.setAuthTag(Buffer.from(payload.authTag, 'base64')); - - const decrypted = Buffer.concat([ - decipher.update(Buffer.from(payload.ciphertext, 'base64')), - decipher.final() - ]); - - return decrypted.toString('utf8'); - } catch (error) { - console.error('Failed to decrypt message:', error); - throw new Error('Decryption failed'); - } -} - -/** - * Sign a message payload for authenticity verification - */ -export function signPayload(payload: string, privateKey: string): string { - try { - const sign = crypto.createSign('SHA256'); - sign.update(payload); - sign.end(); - - const signature = sign.sign(privateKey, 'base64'); - return signature; - } catch (error) { - console.error('Failed to sign payload:', error); - throw new Error('Signing failed'); - } -} - -/** - * Verify a signed payload - */ -export function verifySignature(payload: string, signature: string, publicKey: string): boolean { - try { - const verify = crypto.createVerify('SHA256'); - verify.update(payload); - verify.end(); - - return verify.verify(publicKey, signature, 'base64'); - } catch (error) { - console.error('Failed to verify signature:', error); - return false; - } -} diff --git a/src/lib/swarm/chat-types.ts b/src/lib/swarm/chat-types.ts deleted file mode 100644 index 46f9f65..0000000 --- a/src/lib/swarm/chat-types.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Swarm Chat Types - * - * Type definitions for the swarm chat system. - */ - -export interface SwarmChatMessage { - id: string; - conversationId: string; - senderHandle: string; - senderDisplayName?: string; - senderAvatarUrl?: string; - senderNodeDomain?: string; - encryptedContent: string; - deliveredAt?: string; - readAt?: string; - createdAt: string; -} - -export interface SwarmChatConversation { - id: string; - type: 'direct' | 'group'; - participant1Id: string; - participant2Handle: string; - lastMessageAt?: string; - lastMessagePreview?: string; - unreadCount?: number; - createdAt: string; - updatedAt: string; -} - -/** - * Payload for sending a chat message to a remote node - */ -export interface SwarmChatMessagePayload { - messageId: string; - senderHandle: string; - senderDisplayName?: string; - senderAvatarUrl?: string; - senderNodeDomain: string; - recipientHandle: string; - encryptedContent: string; - timestamp: string; - signature?: string; -} - -/** - * Payload for typing indicator - */ -export interface SwarmChatTypingPayload { - senderHandle: string; - senderNodeDomain: string; - recipientHandle: string; - isTyping: boolean; - timestamp: string; -} - -/** - * Payload for read receipt - */ -export interface SwarmChatReadReceiptPayload { - messageId: string; - readerHandle: string; - readerNodeDomain: string; - timestamp: string; -} diff --git a/src/lib/types.ts b/src/lib/types.ts index bfebe61..f859f67 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,7 +17,8 @@ export interface User { isSwarm?: boolean; // Whether this user is from a Synapsis swarm node nodeDomain?: string | null; // Domain of the node this user is from (for swarm users) did?: string; - chatPublicKey?: string; + canReceiveDms?: boolean; + dmPrivacy?: 'everyone' | 'following' | 'none'; botOwner?: { id: string; handle: string;