Refactor chat encryption to use sodium, update API

Replaces legacy chat encryption modules with new sodium-based implementation. Adds and updates API routes for chat key management and debugging, introduces new hooks and scripts for sodium chat, and updates related database schema and configuration. Removes old crypto modules and updates dependencies accordingly.
This commit is contained in:
Christopher
2026-01-28 06:07:03 -08:00
parent 222f1c6d8a
commit a87977241c
28 changed files with 10701 additions and 2052 deletions
+101 -60
View File
@@ -1,79 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatDeviceBundles, chatOneTimeKeys } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { chatDeviceBundles } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
/**
* GET /api/chat/keys?did=<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),
});
console.log('[Chat Keys GET] Found 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,
});
}
if (!bundle) {
return NextResponse.json({ error: 'No keys found' }, { status: 404 });
}
// For libsodium, we just need the public key
return NextResponse.json({
publicKey: bundle.identityKey, // Reusing identityKey field for libsodium public key
});
} 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 a new Device Bundle and OTKs.
* Publish public key
*
* Payload: SignedAction < {
* deviceId: string,
* identityKey: string,
* signedPreKey: { id, key, sig },
* signature: string, (The ECDSA signature of the bundle itself)
* oneTimeKeys: { id, 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;
// 1. Verify User Identity (ECDSA Root)
// The wrapper itself is a SignedAction with action="chat.keys.publish"
// This proves the user (DID) is authorizing this device registration.
const user = await requireSignedAction(body);
console.log('[Chat Keys POST] Received publicKey:', publicKey ? publicKey.substring(0, 20) + '...' : 'MISSING');
const { deviceId, identityKey, signedPreKey, signature, oneTimeKeys } = body.data;
if (!publicKey) {
return NextResponse.json({ error: 'Missing publicKey' }, { status: 400 });
}
// 2. Validate Bundle Signature (The bundle.signature must cover the fields)
// Actually, requireSignedAction already verified the payload signature.
// The "signature" field inside data is redundant if the whole thing is signed by DID?
// Or is "signature" the signature of the bundle bytes?
// In our design, the SignedAction *is* the signature.
// So "signature" inside might be the "Self-Signature" of the X25519 Identity Key signing the structure?
// No, standard Signal: The Bundle is signed by Identity Key.
// Here, Identity Key is ECDSA. The SignedAction covers it.
// So we just trust the SignedAction.
// 3. Upsert Bundle
await db.transaction(async (tx) => {
// Upsert device bundle
await tx.delete(chatDeviceBundles).where(
and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, deviceId)
)
);
const [bundle] = await tx.insert(chatDeviceBundles).values({
userId: user.id,
did: user.did,
deviceId,
identityKey,
signedPreKey: JSON.stringify(signedPreKey),
signature, // We store the action signature or the explicit inner signature provided
}).returning();
// 4. Insert OTKs
if (oneTimeKeys && oneTimeKeys.length > 0) {
await tx.insert(chatOneTimeKeys).values(
oneTimeKeys.map((k: any) => ({
userId: user.id,
bundleId: bundle.id,
keyId: k.id,
publicKey: k.key
}))
).onConflictDoNothing();
}
// Check if bundle exists
const existing = await db.query.chatDeviceBundles.findFirst({
where: and(
eq(chatDeviceBundles.userId, user.id),
eq(chatDeviceBundles.deviceId, '1')
)
});
return NextResponse.json({ success: true, count: oneTimeKeys?.length || 0 });
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 {
// Insert new bundle
console.log('[Chat Keys POST] Inserting new bundle...');
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');
}
console.log('[Chat Keys POST] Key published successfully for', user.handle);
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Failed to publish keys:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
console.error('[Chat Keys] Failed to publish key:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
+64 -160
View File
@@ -1,212 +1,116 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatInbox, chatConversations, chatMessages, users } from '@/db/schema';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { chatConversations, chatMessages, users } from '@/db/schema';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import { signedFetch } from '@/lib/api/signed-fetch';
/**
* POST /api/chat/send
* Deliver an encrypted envelope to a local or remote user's inbox.
* Store encrypted message (server never decrypts)
*
* Body: {
* recipientDid: string,
* senderPublicKey: string (base64),
* ciphertext: string (base64),
* nonce: string (base64),
* recipientHandle?: string
* }
*/
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const body = await request.json();
const { recipientDid, senderPublicKey, ciphertext, nonce, recipientHandle } = body;
// 1. Verify Envelope Signature (Anti-Spoofing & Replay)
const user = await requireSignedAction(body);
const { action, data } = body;
if (action !== 'chat.deliver') {
return NextResponse.json({ error: 'Invalid action type' }, { status: 400 });
if (!recipientDid || !senderPublicKey || !ciphertext || !nonce) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const { recipientDid, recipientDeviceId, ciphertext, nodeDomain, recipientHandle } = data;
if (!recipientDid || !ciphertext) {
return NextResponse.json({ error: 'Missing required delivery fields' }, { status: 400 });
}
// 2. Check if recipient is local or remote
// Check if recipient is local
const recipientUser = await db.query.users.findFirst({
where: eq(users.did, recipientDid)
});
if (recipientUser) {
// LOCAL RECIPIENT - Store in local inbox
// LOCAL RECIPIENT
// Ensure conversation exists for recipient
let conversation = await db.query.chatConversations.findFirst({
// Ensure conversations exist
let recipientConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, user.handle)
)
});
if (!conversation) {
if (!recipientConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: user.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
lastMessagePreview: '[Encrypted]'
}).returning();
conversation = newConv;
recipientConv = newConv;
}
// Ensure conversation exists for sender
let senderConversation = await db.query.chatConversations.findFirst({
let senderConv = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientUser.handle)
)
});
if (!senderConversation) {
await db.insert(chatConversations).values({
if (!senderConv) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientUser.handle,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
});
lastMessagePreview: '[Encrypted]'
}).returning();
senderConv = newConv;
}
// Insert into local inbox
await db.insert(chatInbox).values({
// 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
await db.insert(chatMessages).values({
conversationId: recipientConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
recipientDid,
recipientDeviceId: recipientDeviceId || null,
envelope: JSON.stringify(body),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
encryptedContent: JSON.stringify(messageData),
deliveredAt: new Date(),
});
return NextResponse.json({ success: true, local: true });
// Create message for sender
await db.insert(chatMessages).values({
conversationId: senderConv.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null,
senderDid: user.did,
encryptedContent: JSON.stringify(messageData),
deliveredAt: new Date(),
});
return NextResponse.json({ success: true });
} else {
// REMOTE RECIPIENT - Forward to their node
let targetNodeDomain = nodeDomain;
if (!targetNodeDomain) {
// Try to extract from DID
if (recipientDid.startsWith('did:web:')) {
targetNodeDomain = recipientDid.replace('did:web:', '');
} else {
return NextResponse.json({
error: 'Remote delivery requires node domain'
}, { status: 400 });
}
}
// Forward the signed envelope to the remote node
const remoteUrl = `https://${targetNodeDomain}/api/swarm/chat/inbox`;
try {
console.log('[Chat Send] Forwarding to remote:', remoteUrl);
// Get or create conversation for sender
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, user.id),
eq(chatConversations.participant2Handle, recipientHandle || recipientDid)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: user.id,
participant2Handle: recipientHandle || recipientDid,
lastMessageAt: new Date(),
lastMessagePreview: '[Encrypted Message]'
}).returning();
conversation = newConv;
console.log('[Chat Send] Created conversation:', conversation.id);
}
// Store message locally so sender can see it
const messageId = crypto.randomUUID();
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const swarmMessageId = `swarm:${localDomain}:${messageId}`;
// Store full envelope data for decryption
const envelopeData = {
did: user.did,
handle: user.handle,
ciphertext: ciphertext
};
const [newMessage] = await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: user.handle,
senderDisplayName: user.displayName,
senderAvatarUrl: user.avatarUrl,
senderNodeDomain: null, // Local sender
encryptedContent: JSON.stringify(envelopeData), // Full envelope
senderChatPublicKey: null, // V2 E2E - keys are in the envelope
swarmMessageId,
deliveredAt: null, // Will update when remote confirms
readAt: null,
}).returning();
console.log('[Chat Send] Stored local message:', newMessage.id);
const response = await fetch(remoteUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { raw: errorText };
}
console.error('[Chat Send] Remote delivery failed:', response.status, errorData);
// Check if remote node doesn't support V2 (returns V1 validation errors)
const isV1ValidationError = errorData.details &&
Array.isArray(errorData.details) &&
errorData.details.some((d: any) =>
d.path && ['messageId', 'senderHandle', 'senderNodeDomain', 'recipientHandle', 'encryptedContent', 'timestamp'].includes(d.path[0])
);
if (isV1ValidationError) {
return NextResponse.json({
error: 'Remote node needs update',
details: 'The recipient node is running an older version that does not support the V2 chat protocol. Please ask the node administrator to update to the latest version.',
remoteError: errorData
}, { status: 400 });
}
return NextResponse.json({
error: 'Remote delivery failed',
details: errorData
}, { status: response.status });
}
// Mark message as delivered since remote accepted it
await db.update(chatMessages)
.set({ deliveredAt: new Date() })
.where(eq(chatMessages.id, newMessage.id));
console.log('[Chat Send] Message marked as delivered');
return NextResponse.json({ success: true, remote: true, messageId: newMessage.id });
} catch (error: any) {
console.error('[Chat Send] Remote delivery error:', error);
return NextResponse.json({
error: 'Failed to reach remote node',
details: error.message
}, { status: 500 });
}
// REMOTE RECIPIENT - not implemented yet
return NextResponse.json({ error: 'Remote delivery not yet implemented' }, { status: 501 });
}
} catch (error: any) {
console.error('Delivery failed:', error);
return NextResponse.json({ error: error.message }, { status: 400 });
console.error('Send failed:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
}
@@ -0,0 +1,50 @@
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 });
}
}
+19
View File
@@ -0,0 +1,19 @@
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 });
}
}
@@ -0,0 +1,18 @@
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 });
}
}
+17 -2
View File
@@ -33,7 +33,7 @@ const parseRemoteHandleQuery = (query: string): { handle: string; domain: string
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
let query = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all'; // all, users, posts
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
@@ -50,7 +50,22 @@ export async function GET(request: Request) {
});
}
const searchPattern = `%${query}%`;
// Normalize query for local user search
// Strip leading @ and local domain if present
let localSearchQuery = query.trim();
if (localSearchQuery.startsWith('@')) {
localSearchQuery = localSearchQuery.slice(1);
}
// Remove local domain if searching like "admin2@dev.syn.quest"
const localDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN;
if (localDomain && localSearchQuery.includes('@')) {
const parts = localSearchQuery.split('@');
if (parts[1] === localDomain) {
localSearchQuery = parts[0];
}
}
const searchPattern = `%${localSearchQuery}%`;
let searchUsers: SearchUser[] = [];
let searchPosts: typeof posts.$inferSelect[] = [];
+8 -3
View File
@@ -64,8 +64,13 @@ export async function POST(request: NextRequest) {
// Also create conversation and message for recipient UI
// Extract sender info from envelope
const senderHandle = body.handle || body.did; // Fallback to DID if no handle
const senderFullHandle = `${senderHandle}@${body.did?.split(':')[2] || 'unknown'}`; // Extract domain from DID
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({
@@ -108,7 +113,7 @@ export async function POST(request: NextRequest) {
senderHandle: senderFullHandle,
senderDisplayName: null, // Unknown until decrypted
senderAvatarUrl: null,
senderNodeDomain: body.did?.split(':')[2] || null,
senderNodeDomain: senderNodeDomain !== 'unknown' ? senderNodeDomain : null,
encryptedContent: JSON.stringify(envelopeData), // Full envelope for decryption
senderChatPublicKey: null,
swarmMessageId: `swarm:v2:${messageId}`,