Add sender DID resolution and self-decryption for chat messages
The messages API now resolves and includes sender DIDs for received messages, enabling proper decryption in the chat UI. The chat page adds logic to decrypt self-encrypted messages for sent items and improves V2 envelope handling, while useChatEncryption now serializes ratchet state before storage.
This commit is contained in:
@@ -98,6 +98,38 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
console.log('[Messages API] Recipient public key found:', !!recipientPublicKey);
|
console.log('[Messages API] Recipient public key found:', !!recipientPublicKey);
|
||||||
|
|
||||||
|
// Get sender DID for received messages
|
||||||
|
const senderDids = new Map<string, string>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
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 messagesWithDecryption = messages.map((msg) => {
|
||||||
const isSentByMe = msg.senderHandle === session.user.handle;
|
const isSentByMe = msg.senderHandle === session.user.handle;
|
||||||
|
|
||||||
@@ -110,6 +142,7 @@ export async function GET(request: NextRequest) {
|
|||||||
senderHandle: msg.senderHandle,
|
senderHandle: msg.senderHandle,
|
||||||
senderDisplayName: msg.senderDisplayName,
|
senderDisplayName: msg.senderDisplayName,
|
||||||
senderAvatarUrl: msg.senderAvatarUrl,
|
senderAvatarUrl: msg.senderAvatarUrl,
|
||||||
|
senderDid: isSentByMe ? undefined : senderDids.get(msg.senderHandle), // Add DID for received messages
|
||||||
// For decryption:
|
// For decryption:
|
||||||
// - Sent messages: need recipient's public key
|
// - Sent messages: need recipient's public key
|
||||||
// - Received messages: need sender's public key
|
// - Received messages: need sender's public key
|
||||||
|
|||||||
+116
-63
@@ -4,10 +4,42 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
import { useChatEncryption } from '@/lib/hooks/useChatEncryption';
|
||||||
|
import { loadDeviceKeys } from '@/lib/crypto/chat-storage';
|
||||||
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
|
||||||
import { formatFullHandle } from '@/lib/utils/handle';
|
import { formatFullHandle } from '@/lib/utils/handle';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
// Helper to decrypt self-encrypted message (copied from useChatEncryption)
|
||||||
|
async function decryptForSelf(selfEncrypted: { ciphertext: string; iv: string }, identityKeyPair: CryptoKeyPair): Promise<string> {
|
||||||
|
// Export public key raw to use as key material (same as encryption)
|
||||||
|
const keyMaterial = await crypto.subtle.exportKey('raw', identityKeyPair.publicKey);
|
||||||
|
|
||||||
|
// Derive AES key
|
||||||
|
const aesKey = await crypto.subtle.digest('SHA-256', keyMaterial);
|
||||||
|
|
||||||
|
// Import as AES-GCM key
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
aesKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decode IV and ciphertext
|
||||||
|
const iv = Uint8Array.from(atob(selfEncrypted.iv), c => c.charCodeAt(0));
|
||||||
|
const ciphertext = Uint8Array.from(atob(selfEncrypted.ciphertext), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
cryptoKey,
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
participant2: {
|
participant2: {
|
||||||
@@ -158,88 +190,109 @@ export default function ChatPage() {
|
|||||||
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
|
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Resolve DIDs if needed?
|
|
||||||
// V2: We need Sender DID to decrypt.
|
|
||||||
// The API response should include senderDid if possible.
|
|
||||||
// If not, we have handle.
|
|
||||||
// But encryption is bound to DID.
|
|
||||||
// We'll rely on `senderNodeDomain`?
|
|
||||||
|
|
||||||
const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => {
|
const decrypted = await Promise.all((data.messages || []).map(async (msg: any) => {
|
||||||
try {
|
try {
|
||||||
// Try V2 Decryption
|
console.log('[Chat UI] Processing message:', {
|
||||||
// Construct a fake envelope-like structure expected by our hook
|
id: msg.id,
|
||||||
// We assume `encryptedContent` IS the V2 payload JSON.
|
isSentByMe: msg.isSentByMe,
|
||||||
// And we need `senderDid`.
|
hasEncryptedContent: !!msg.encryptedContent,
|
||||||
// Does msg have `senderDid`?
|
contentPreview: msg.encryptedContent?.substring(0, 100)
|
||||||
// If not, we might need to resolve it or `senderHandle`.
|
});
|
||||||
// Let's guess senderDid from cache or user info?
|
|
||||||
|
|
||||||
let senderDid = msg.senderDid;
|
// Check if this is a V2 encrypted message (JSON envelope)
|
||||||
if (!senderDid) {
|
|
||||||
// Fallback: This might fail if we don't know the DID.
|
|
||||||
// Can we resolve handle?
|
|
||||||
// Ideally the backend message object includes DID.
|
|
||||||
// If not, decryption returns error.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: In V2, 'isSentByMe' means we can decrypt using OUR session with recipient?
|
|
||||||
// No, `isSentByMe` means WE encrypted it.
|
|
||||||
// We should have stored the `plaintext` locally or a `self-encrypted` copy?
|
|
||||||
// Ratchet implementations often encrypt a copy for the sender.
|
|
||||||
// My `sendMessage` implementation (Step 488) did NOT encrypt for self explicitly in the DB payload logic.
|
|
||||||
// However, `api/chat/send` stored `envelope` in `chatInbox` (Local).
|
|
||||||
// If `isSentByMe`, the `recipientDeviceId` in the stored envelope is... ?
|
|
||||||
// In `sendMessage`, I iterated over Recipient Bundles.
|
|
||||||
// I did NOT creating a bundle for myself.
|
|
||||||
// SO: I cannot decrypt my own sent messages unless I stored them plaintext or self-encrypted.
|
|
||||||
// In the previous V2 hook, I updated `activeMessages` optimistically.
|
|
||||||
|
|
||||||
// If these messages are "Me" messages from another device, I can't read them!
|
|
||||||
// This is a known V2 Ratchet limitation if not explicitly handling "Self-Send".
|
|
||||||
// For now, I'll display "[Encrypted Sync]" or similar if I can't decrypt.
|
|
||||||
|
|
||||||
if (msg.isSentByMe) {
|
|
||||||
// Optimistic approach: We might not be able to decrypt our own history from other devices yet.
|
|
||||||
// Unless I implement "Encrypt to Self" loop.
|
|
||||||
// I will display the content if it's plaintext (legacy) or placeholder.
|
|
||||||
if (!msg.encryptedContent) return msg;
|
|
||||||
// return { ...msg, decryptedContent: '[Sent Message]' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptedContent is now the full envelope JSON
|
|
||||||
if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) {
|
if (msg.encryptedContent && msg.encryptedContent.startsWith('{')) {
|
||||||
try {
|
try {
|
||||||
|
// First layer: { did, handle, ciphertext }
|
||||||
const envelope = JSON.parse(msg.encryptedContent);
|
const envelope = JSON.parse(msg.encryptedContent);
|
||||||
// envelope contains { did, handle, ciphertext }
|
console.log('[Chat UI] Parsed envelope:', {
|
||||||
const envelopeMock = {
|
hasDid: !!envelope.did,
|
||||||
did: envelope.did,
|
hasHandle: !!envelope.handle,
|
||||||
data: {
|
hasCiphertext: !!envelope.ciphertext,
|
||||||
ciphertext: envelope.ciphertext
|
ciphertextPreview: envelope.ciphertext?.substring(0, 100)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second layer: the actual payload with selfEncrypted
|
||||||
|
let payload;
|
||||||
|
if (envelope.ciphertext && typeof envelope.ciphertext === 'string' && envelope.ciphertext.startsWith('{')) {
|
||||||
|
payload = JSON.parse(envelope.ciphertext);
|
||||||
|
console.log('[Chat UI] Parsed V2 payload:', {
|
||||||
|
hasSelfEncrypted: !!payload.selfEncrypted,
|
||||||
|
hasCiphertext: !!payload.ciphertext,
|
||||||
|
recipientDeviceId: payload.recipientDeviceId,
|
||||||
|
senderDeviceId: payload.senderDeviceId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Old format or malformed
|
||||||
|
payload = envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For messages we sent, try to decrypt the selfEncrypted copy
|
||||||
|
if (msg.isSentByMe && payload.selfEncrypted && user?.did) {
|
||||||
|
console.log('[Chat UI] Attempting to decrypt self-encrypted message');
|
||||||
|
try {
|
||||||
|
const localKeys = await loadDeviceKeys();
|
||||||
|
if (localKeys) {
|
||||||
|
const plaintext = await decryptForSelf(payload.selfEncrypted, localKeys.identity);
|
||||||
|
console.log('[Chat UI] Self-decrypt successful:', plaintext.substring(0, 50));
|
||||||
|
return { ...msg, decryptedContent: plaintext };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Chat UI] Self-decrypt failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For messages we received, decrypt normally
|
||||||
|
if (!msg.isSentByMe) {
|
||||||
|
console.log('[Chat UI] Attempting to decrypt received message');
|
||||||
|
// Need to get the sender's DID
|
||||||
|
let senderDid = msg.senderDid || envelope.did;
|
||||||
|
|
||||||
|
if (!senderDid && msg.senderHandle) {
|
||||||
|
// Try to resolve DID from handle
|
||||||
|
try {
|
||||||
|
const userRes = await fetch(`/api/users/${encodeURIComponent(msg.senderHandle)}`);
|
||||||
|
if (userRes.ok) {
|
||||||
|
const userData = await userRes.json();
|
||||||
|
senderDid = userData.user?.did;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Chat UI] Failed to resolve sender DID:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderDid) {
|
||||||
|
// Create the envelope structure that decryptMessage expects
|
||||||
|
const decryptEnvelope = {
|
||||||
|
did: senderDid,
|
||||||
|
data: {
|
||||||
|
ciphertext: envelope.ciphertext // Pass the inner payload JSON string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dec = await decryptMessage(decryptEnvelope);
|
||||||
|
console.log('[Chat UI] Received decrypt result:', dec.substring(0, 50));
|
||||||
|
if (!dec.startsWith('[')) {
|
||||||
|
return { ...msg, decryptedContent: dec };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
const dec = await decryptMessage(envelopeMock);
|
|
||||||
if (!dec.startsWith('[')) {
|
|
||||||
return { ...msg, decryptedContent: dec };
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Chat] Failed to parse envelope:', e);
|
console.error('[Chat UI] V2 decryption failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy Message types?
|
// Fallback: show encrypted indicator
|
||||||
if (!msg.senderPublicKey && !msg.encryptedContent.startsWith('{')) {
|
console.log('[Chat UI] Could not decrypt message, showing encrypted');
|
||||||
return { ...msg, decryptedContent: '[Legacy Message]' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...msg, decryptedContent: '[Encrypted]' };
|
return { ...msg, decryptedContent: '[Encrypted]' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[Chat UI] Message processing error:', err);
|
||||||
return { ...msg, decryptedContent: '[Error]' };
|
return { ...msg, decryptedContent: '[Error]' };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setMessages(decrypted);
|
setMessages(decrypted);
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) {
|
||||||
|
console.error('[Chat UI] Load messages error:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsRead = async (conversationId: string) => {
|
const markAsRead = async (conversationId: string) => {
|
||||||
|
|||||||
@@ -612,7 +612,8 @@ export function useChatEncryption() {
|
|||||||
|
|
||||||
// 5. Update Session
|
// 5. Update Session
|
||||||
sessionsRef.current.set(sessionKey, newState);
|
sessionsRef.current.set(sessionKey, newState);
|
||||||
await storeEncrypted(sessionKey, newState);
|
const serialized = await serializeRatchetState(newState);
|
||||||
|
await storeEncrypted(sessionKey, serialized);
|
||||||
|
|
||||||
return plaintext;
|
return plaintext;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user