From 03be8784b55c1bb12e6882160f64fd2a292d80d3 Mon Sep 17 00:00:00 2001 From: Jo-anny Date: Tue, 30 Jun 2026 21:00:53 +0100 Subject: [PATCH] Implement inbound decryption and render pipeline for web (#296) Add envelope processing with sender device key lookup, in-memory plaintext cache, sync/live delivery handlers, and graceful placeholders for pre-link or undecryptable messages. --- apps/backend/src/app.ts | 2 + apps/backend/src/routes/sync.ts | 8 + apps/backend/src/routes/userDevices.ts | 68 +++++ apps/web/src/app/conversations/[id]/page.tsx | 112 ++----- .../messaging/InboundMessageRow.tsx | 42 +++ .../UnavailableMessagePlaceholder.tsx | 27 ++ apps/web/src/hooks/useInboundPipeline.ts | 279 ++++++++++++++++++ apps/web/src/lib/crypto/decrypt.ts | 106 +++++++ apps/web/src/lib/crypto/deviceKeys.ts | 36 +++ apps/web/src/lib/crypto/plaintextCache.ts | 26 ++ apps/web/src/lib/crypto/processEnvelope.ts | 88 ++++++ apps/web/src/lib/crypto/sessionStore.ts | 34 +++ apps/web/src/lib/crypto/types.ts | 86 ++++++ apps/web/src/lib/jwt.ts | 35 +++ 14 files changed, 869 insertions(+), 80 deletions(-) create mode 100644 apps/backend/src/routes/userDevices.ts create mode 100644 apps/web/src/components/messaging/InboundMessageRow.tsx create mode 100644 apps/web/src/components/messaging/UnavailableMessagePlaceholder.tsx create mode 100644 apps/web/src/hooks/useInboundPipeline.ts create mode 100644 apps/web/src/lib/crypto/decrypt.ts create mode 100644 apps/web/src/lib/crypto/deviceKeys.ts create mode 100644 apps/web/src/lib/crypto/plaintextCache.ts create mode 100644 apps/web/src/lib/crypto/processEnvelope.ts create mode 100644 apps/web/src/lib/crypto/sessionStore.ts create mode 100644 apps/web/src/lib/crypto/types.ts create mode 100644 apps/web/src/lib/jwt.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 38c54a5..3c85f4f 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -14,6 +14,7 @@ import { treasuryRouter } from './routes/treasury.js'; import { filesRouter } from './routes/files.js'; import { pushRouter } from './routes/push.js'; import { syncRouter } from './routes/sync.js'; +import { userDevicesRouter } from './routes/userDevices.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -57,6 +58,7 @@ app.use('/treasury', treasuryRouter); app.use('/files', filesRouter); app.use('/push', pushRouter); app.use('/sync', syncRouter); +app.use('/user-devices', userDevicesRouter); app.get('/me', requireAuth, (req, res) => { res.json({ user: (req as AuthRequest).auth }); diff --git a/apps/backend/src/routes/sync.ts b/apps/backend/src/routes/sync.ts index 3a08c89..12ad7a7 100644 --- a/apps/backend/src/routes/sync.ts +++ b/apps/backend/src/routes/sync.ts @@ -80,6 +80,10 @@ syncRouter.get('/', async (req: AuthRequest, res) => { createdAt: messageEnvelopes.createdAt, sequenceNumber: messages.sequenceNumber, conversationId: messages.conversationId, + senderId: messages.senderId, + senderDeviceId: messages.senderDeviceId, + contentType: messages.contentType, + messageCreatedAt: messages.createdAt, }) .from(messageEnvelopes) .innerJoin(messages, eq(messageEnvelopes.messageId, messages.id)) @@ -118,8 +122,12 @@ syncRouter.get('/', async (req: AuthRequest, res) => { conversationId: r.conversationId, ciphertext: r.ciphertext, sequenceNumber: r.sequenceNumber, + senderId: r.senderId, + senderDeviceId: r.senderDeviceId, + contentType: r.contentType, deliveredAt: r.deliveredAt, createdAt: r.createdAt, + messageCreatedAt: r.messageCreatedAt, })), nextCursor, hasMore, diff --git a/apps/backend/src/routes/userDevices.ts b/apps/backend/src/routes/userDevices.ts new file mode 100644 index 0000000..a12ecd3 --- /dev/null +++ b/apps/backend/src/routes/userDevices.ts @@ -0,0 +1,68 @@ +/** + * User-device routes — public key lookup for inbound decryption (#122). + * + * GET /user-devices/:id/public-key + * Returns the identity public key for a sender device when the caller shares + * a conversation with the device owner. + */ + +import { Router, type Router as RouterType } from 'express'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { conversationMembers, userDevices } from '../db/schema.js'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; + +export const userDevicesRouter: RouterType = Router(); + +userDevicesRouter.use(requireAuth); + +userDevicesRouter.get('/:id/public-key', async (req: AuthRequest, res) => { + const senderDeviceId = req.params['id'] as string; + const requesterId = req.auth!.userId; + + try { + const device = await db.query.userDevices.findFirst({ + where: and(eq(userDevices.id, senderDeviceId), isNull(userDevices.revokedAt)), + columns: { + id: true, + userId: true, + identityPublicKey: true, + }, + }); + + if (!device) { + res.status(404).json({ error: 'Device not found or revoked' }); + return; + } + + // Caller must share at least one conversation with the device owner. + const ownerConversations = db + .select({ conversationId: conversationMembers.conversationId }) + .from(conversationMembers) + .where(eq(conversationMembers.userId, device.userId)); + + const [coMember] = await db + .select({ conversationId: conversationMembers.conversationId }) + .from(conversationMembers) + .where( + and( + eq(conversationMembers.userId, requesterId), + inArray(conversationMembers.conversationId, ownerConversations), + ), + ) + .limit(1); + + if (!coMember) { + res.status(403).json({ error: 'No shared conversation with device owner' }); + return; + } + + res.json({ + id: device.id, + userId: device.userId, + identityPublicKey: device.identityPublicKey, + }); + } catch { + res.status(500).json({ error: 'Failed to fetch device public key' }); + } +}); diff --git a/apps/web/src/app/conversations/[id]/page.tsx b/apps/web/src/app/conversations/[id]/page.tsx index f2516eb..9ab65dc 100644 --- a/apps/web/src/app/conversations/[id]/page.tsx +++ b/apps/web/src/app/conversations/[id]/page.tsx @@ -1,30 +1,12 @@ 'use client'; -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import Image from 'next/image'; import { useParams } from 'next/navigation'; import { useSocket } from '@/hooks/useSocket'; - -interface Sender { - id: string; - username: string | null; - avatarUrl: string | null; -} - -interface Message { - id: string; - conversationId: string; - senderId: string; - content: string; - createdAt: string; - sender: Sender; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function formatTime(iso: string) { - return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} +import { useInboundPipeline } from '@/hooks/useInboundPipeline'; +import { InboundMessageRow } from '@/components/messaging/InboundMessageRow'; +import { parseJwtPayload } from '@/lib/jwt'; function formatDateLabel(iso: string) { const d = new Date(iso); @@ -60,22 +42,22 @@ function Avatar({ src, name }: { src: string | null; name: string }) { ); } -// ── Component ───────────────────────────────────────────────────────────────── - export default function ConversationPage() { const { id } = useParams<{ id: string }>(); - // TODO: replace with real auth token from your auth context/store - const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; - // TODO: replace with real current user id from your auth context/store - const currentUserId = typeof window !== 'undefined' ? localStorage.getItem('userId') : null; + const token = + typeof window !== 'undefined' + ? (localStorage.getItem('clicked.jwt') ?? localStorage.getItem('token')) + : null; + const currentUserId = + typeof window !== 'undefined' && token ? (parseJwtPayload(token)?.userId ?? null) : null; const socket = useSocket(token); - const [messages, setMessages] = useState([]); + const { messages, syncing } = useInboundPipeline({ socket, token, conversationId: id }); + const bottomRef = useRef(null); const containerRef = useRef(null); - // Scroll to bottom only when user is already near the bottom const scrollToBottom = useCallback((force = false) => { const el = containerRef.current; if (!el) return; @@ -89,35 +71,21 @@ export default function ConversationPage() { if (!socket) return; socket.emit('join_room', { conversationId: id }); - socket.emit('message_history', { conversationId: id }); - - socket.on('message_history', (data: { conversationId: string; messages: Message[] }) => { - if (data.conversationId === id) { - setMessages(data.messages); - // Force scroll on initial load - setTimeout(() => scrollToBottom(true), 50); - } - }); - - socket.on('new_message', (msg: Message) => { - if (msg.conversationId === id) { - setMessages((prev) => [...prev, msg]); - scrollToBottom(); - } - }); return () => { - socket.off('message_history'); - socket.off('new_message'); + socket.emit('leave_room', { conversationId: id }); }; - }, [socket, id, scrollToBottom]); + }, [socket, id]); - // ── Group messages by day ────────────────────────────────────────────────── - const grouped: { label: string; messages: Message[] }[] = []; + useEffect(() => { + scrollToBottom(true); + }, [messages.length, scrollToBottom]); + + const grouped: { label: string; messages: typeof messages }[] = []; for (const msg of messages) { const key = dayKey(msg.createdAt); const last = grouped[grouped.length - 1]; - if (last && dayKey(last.messages[0].createdAt) === key) { + if (last && dayKey(last.messages[0]!.createdAt) === key) { last.messages.push(msg); } else { grouped.push({ label: formatDateLabel(msg.createdAt), messages: [msg] }); @@ -126,16 +94,20 @@ export default function ConversationPage() { return (
- {/* Header */}

Conversation

+ {syncing && ( +

Syncing encrypted messages…

+ )}
- {/* Message thread */}
+ {messages.length === 0 && !syncing && ( +

No messages yet.

+ )} + {grouped.map((group) => (
- {/* Date separator */}
{group.label} @@ -145,36 +117,16 @@ export default function ConversationPage() {
{group.messages.map((msg) => { const isSelf = msg.senderId === currentUserId; - const name = msg.sender.username ?? 'Unknown'; + const name = isSelf ? 'You' : 'Contact'; return (
- {!isSelf && } - -
- {!isSelf && ( - {name} - )} -
- {msg.content} -
- - {formatTime(msg.createdAt)} - -
- - {isSelf && } + {!isSelf && } + + {isSelf && }
); })} diff --git a/apps/web/src/components/messaging/InboundMessageRow.tsx b/apps/web/src/components/messaging/InboundMessageRow.tsx new file mode 100644 index 0000000..a00d10a --- /dev/null +++ b/apps/web/src/components/messaging/InboundMessageRow.tsx @@ -0,0 +1,42 @@ +import type { InboundMessage } from '@/lib/crypto/types'; +import { UnavailableMessagePlaceholder } from './UnavailableMessagePlaceholder'; + +interface InboundMessageRowProps { + message: InboundMessage; + isSelf: boolean; + senderName?: string; +} + +function formatTime(iso: string) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export function InboundMessageRow({ message, isSelf, senderName }: InboundMessageRowProps) { + return ( +
+ {!isSelf && senderName && ( + {senderName} + )} + + {message.status === 'decrypted' && message.plaintext ? ( +
+ {message.plaintext} +
+ ) : message.status === 'unavailable' && message.unavailableReason ? ( + + ) : ( +
+ Decrypting… +
+ )} + + {formatTime(message.createdAt)} +
+ ); +} diff --git a/apps/web/src/components/messaging/UnavailableMessagePlaceholder.tsx b/apps/web/src/components/messaging/UnavailableMessagePlaceholder.tsx new file mode 100644 index 0000000..7d08380 --- /dev/null +++ b/apps/web/src/components/messaging/UnavailableMessagePlaceholder.tsx @@ -0,0 +1,27 @@ +import type { UnavailableReason } from '@/lib/crypto/types'; + +const REASON_COPY: Record = { + 'pre-link': 'Waiting for secure session — message from before this device was linked.', + undecryptable: 'Unable to decrypt this message.', + 'verification-failed': 'Message could not be verified.', +}; + +interface UnavailableMessagePlaceholderProps { + reason: UnavailableReason; +} + +/** Graceful placeholder for undecryptable / pre-link messages (#127). */ +export function UnavailableMessagePlaceholder({ reason }: UnavailableMessagePlaceholderProps) { + return ( +
+ + {REASON_COPY[reason]} +
+ ); +} diff --git a/apps/web/src/hooks/useInboundPipeline.ts b/apps/web/src/hooks/useInboundPipeline.ts new file mode 100644 index 0000000..efc7f1a --- /dev/null +++ b/apps/web/src/hooks/useInboundPipeline.ts @@ -0,0 +1,279 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Socket } from 'socket.io-client'; +import { API_BASE_URL } from '@/lib/api'; +import { getE2EDeviceId } from '@/lib/jwt'; +import { + mergeInboundMessage, + processInboundEnvelope, + sortBySequenceNumber, + type EnvelopeInput, +} from '@/lib/crypto/processEnvelope'; +import type { + DeviceEnvelopeEvent, + InboundMessage, + MessageEnvelopeEvent, + SyncEnvelope, +} from '@/lib/crypto/types'; + +interface NewMessageMeta { + id: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + contentType: string; + sequenceNumber: number; + createdAt: string; + unavailable?: boolean; +} + +interface UseInboundPipelineOptions { + socket: Socket | null; + token: string | null; + conversationId: string; +} + +interface UseInboundPipelineReturn { + messages: InboundMessage[]; + syncing: boolean; +} + +/** + * Inbound decryption + render pipeline (#296). + * + * Receives envelopes live (#144) or via sync (#137), looks up the sender + * device's public key by senderDeviceId (#122), decrypts, verifies, and + * exposes messages sorted by sequenceNumber for rendering. + */ +export function useInboundPipeline({ + socket, + token, + conversationId, +}: UseInboundPipelineOptions): UseInboundPipelineReturn { + const [messagesById, setMessagesById] = useState>(new Map()); + const [syncing, setSyncing] = useState(false); + + const pendingCiphertext = useRef( + new Map(), + ); + const pendingMeta = useRef(new Map()); + const syncCursor = useRef(0); + const processing = useRef(new Set()); + + const upsertMessage = useCallback((incoming: InboundMessage) => { + setMessagesById((prev) => { + const next = new Map(prev); + const existing = next.get(incoming.messageId); + next.set(incoming.messageId, mergeInboundMessage(existing, incoming)); + return next; + }); + }, []); + + const handleEnvelope = useCallback( + async (input: EnvelopeInput) => { + if (!token || input.conversationId !== conversationId) return; + if (processing.current.has(input.messageId)) return; + processing.current.add(input.messageId); + + try { + const result = await processInboundEnvelope(input, token); + upsertMessage(result); + } finally { + processing.current.delete(input.messageId); + } + }, + [token, conversationId, upsertMessage], + ); + + const tryProcessPending = useCallback( + (messageId: string) => { + const ciphertextEntry = pendingCiphertext.current.get(messageId); + const meta = pendingMeta.current.get(messageId); + if (!ciphertextEntry || !meta) return; + + pendingCiphertext.current.delete(messageId); + pendingMeta.current.delete(messageId); + + void handleEnvelope({ + messageId, + conversationId: meta.conversationId, + senderId: meta.senderId, + senderDeviceId: meta.senderDeviceId, + sequenceNumber: meta.sequenceNumber, + contentType: meta.contentType, + createdAt: meta.createdAt, + ciphertext: ciphertextEntry.ciphertext, + }); + }, + [handleEnvelope], + ); + + const ingestCiphertext = useCallback( + (messageId: string, ciphertext: string, partial: Partial) => { + if (partial.conversationId && partial.conversationId !== conversationId) return; + + const meta = pendingMeta.current.get(messageId); + if (meta) { + void handleEnvelope({ + messageId, + conversationId: meta.conversationId, + senderId: meta.senderId, + senderDeviceId: meta.senderDeviceId, + sequenceNumber: meta.sequenceNumber, + contentType: meta.contentType, + createdAt: meta.createdAt, + ciphertext, + }); + return; + } + + pendingCiphertext.current.set(messageId, { + ciphertext, + sequenceNumber: partial.sequenceNumber ?? 0, + conversationId: partial.conversationId ?? conversationId, + }); + }, + [conversationId, handleEnvelope], + ); + + const ingestMeta = useCallback( + (meta: NewMessageMeta) => { + if (meta.conversationId !== conversationId) return; + + if (meta.unavailable) { + upsertMessage({ + messageId: meta.id, + conversationId: meta.conversationId, + senderId: meta.senderId, + senderDeviceId: meta.senderDeviceId, + sequenceNumber: meta.sequenceNumber, + contentType: meta.contentType, + createdAt: meta.createdAt, + status: 'unavailable', + unavailableReason: 'pre-link', + }); + return; + } + + pendingMeta.current.set(meta.id, meta); + tryProcessPending(meta.id); + + // Track metadata-only messages so they appear as pending until ciphertext arrives. + upsertMessage({ + messageId: meta.id, + conversationId: meta.conversationId, + senderId: meta.senderId, + senderDeviceId: meta.senderDeviceId, + sequenceNumber: meta.sequenceNumber, + contentType: meta.contentType, + createdAt: meta.createdAt, + status: 'pending', + }); + }, + [conversationId, tryProcessPending, upsertMessage], + ); + + const runSync = useCallback(async () => { + if (!token) return; + const e2eDeviceId = getE2EDeviceId(token); + if (!e2eDeviceId) return; + + setSyncing(true); + try { + let cursor = syncCursor.current; + let hasMore = true; + + while (hasMore) { + const params = new URLSearchParams({ + deviceId: e2eDeviceId, + sinceSequence: String(cursor), + }); + const res = await fetch(`${API_BASE_URL}/sync?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) break; + + const body = (await res.json()) as { + envelopes: SyncEnvelope[]; + nextCursor: number; + hasMore: boolean; + }; + + for (const env of body.envelopes) { + if (env.conversationId !== conversationId) continue; + cursor = Math.max(cursor, env.sequenceNumber); + void handleEnvelope({ + messageId: env.messageId, + conversationId: env.conversationId, + senderId: env.senderId, + senderDeviceId: env.senderDeviceId, + sequenceNumber: env.sequenceNumber, + contentType: env.contentType, + createdAt: env.messageCreatedAt ?? env.createdAt, + ciphertext: env.ciphertext, + }); + } + + syncCursor.current = body.nextCursor; + hasMore = body.hasMore; + } + } finally { + setSyncing(false); + } + }, [token, conversationId, handleEnvelope]); + + // Live envelope delivery (#144). + useEffect(() => { + if (!socket) return; + + function onMessageEnvelope(event: MessageEnvelopeEvent) { + if (event.conversationId !== conversationId) return; + void handleEnvelope({ + messageId: event.messageId, + conversationId: event.conversationId, + senderId: event.senderId, + senderDeviceId: event.senderDeviceId, + sequenceNumber: event.sequenceNumber, + contentType: event.contentType, + createdAt: event.createdAt, + ciphertext: event.ciphertext, + }); + } + + function onDeviceEnvelope(event: DeviceEnvelopeEvent) { + if (event.conversationId !== conversationId) return; + ingestCiphertext(event.messageId, event.ciphertext, { + conversationId: event.conversationId, + sequenceNumber: event.sequenceNumber, + }); + } + + function onNewMessage(msg: NewMessageMeta) { + ingestMeta(msg); + } + + socket.on('message_envelope', onMessageEnvelope); + socket.on('device_envelope', onDeviceEnvelope); + socket.on('new_message', onNewMessage); + + return () => { + socket.off('message_envelope', onMessageEnvelope); + socket.off('device_envelope', onDeviceEnvelope); + socket.off('new_message', onNewMessage); + }; + }, [socket, conversationId, handleEnvelope, ingestCiphertext, ingestMeta]); + + // Offline sync on connect (#137). + useEffect(() => { + if (!token) return; + void runSync(); + }, [token, conversationId, runSync]); + + const messages = useMemo( + () => sortBySequenceNumber(Array.from(messagesById.values())), + [messagesById], + ); + + return { messages, syncing }; +} diff --git a/apps/web/src/lib/crypto/decrypt.ts b/apps/web/src/lib/crypto/decrypt.ts new file mode 100644 index 0000000..db6eb95 --- /dev/null +++ b/apps/web/src/lib/crypto/decrypt.ts @@ -0,0 +1,106 @@ +import { + DecryptError, + PreLinkError, + VerificationFailedError, + type EncryptedEnvelopePayload, +} from './types'; +import { getSessionKey } from './sessionStore'; + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} + +function parseEnvelopePayload(ciphertext: string): EncryptedEnvelopePayload { + let parsed: unknown; + try { + const json = new TextDecoder().decode(base64ToBytes(ciphertext)); + parsed = JSON.parse(json); + } catch { + throw new DecryptError('Invalid envelope format'); + } + + const payload = parsed as Partial; + if (payload.v !== 1 || !payload.iv || !payload.ct) { + throw new DecryptError('Unsupported envelope version'); + } + + return payload as EncryptedEnvelopePayload; +} + +async function importIdentityPublicKey(spkiB64: string): Promise { + return crypto.subtle.importKey( + 'spki', + base64ToBytes(spkiB64), + { name: 'Ed25519' }, + false, + ['verify'], + ); +} + +async function verifyEnvelopeSignature( + identityPublicKeyB64: string, + iv: string, + ct: string, + sigB64: string, +): Promise { + const key = await importIdentityPublicKey(identityPublicKeyB64); + const message = concatBytes(base64ToBytes(iv), base64ToBytes(ct)); + return crypto.subtle.verify('Ed25519', key, base64ToBytes(sigB64), message); +} + +/** + * Decrypt and verify an inbound envelope ciphertext. + * + * 1. Parse the base64 JSON envelope payload. + * 2. Verify the Ed25519 signature against the sender identity key. + * 3. Decrypt the body with the in-memory session AES key. + * + * Throws PreLinkError when no session exists (pre-link message). + */ +export async function decryptAndVerifyEnvelope( + ciphertext: string, + senderDeviceId: string, + senderIdentityPublicKey: string, +): Promise { + const payload = parseEnvelopePayload(ciphertext); + + if (payload.sig) { + const valid = await verifyEnvelopeSignature( + senderIdentityPublicKey, + payload.iv, + payload.ct, + payload.sig, + ); + if (!valid) { + throw new VerificationFailedError(); + } + } + + const sessionKey = getSessionKey(senderDeviceId); + if (!sessionKey) { + throw new PreLinkError(); + } + + try { + const plaintextBytes = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToBytes(payload.iv) }, + sessionKey, + base64ToBytes(payload.ct), + ); + return new TextDecoder().decode(plaintextBytes); + } catch { + throw new DecryptError(); + } +} diff --git a/apps/web/src/lib/crypto/deviceKeys.ts b/apps/web/src/lib/crypto/deviceKeys.ts new file mode 100644 index 0000000..a9a4291 --- /dev/null +++ b/apps/web/src/lib/crypto/deviceKeys.ts @@ -0,0 +1,36 @@ +import { API_BASE_URL } from '@/lib/api'; +import type { DevicePublicKey } from './types'; + +const keyCache = new Map(); + +/** + * Fetch the sender device's identity public key by senderDeviceId (#122). + * Results are cached in memory for the lifetime of the page. + */ +export async function fetchSenderDevicePublicKey( + senderDeviceId: string, + token: string, +): Promise { + const cached = keyCache.get(senderDeviceId); + if (cached) return cached; + + const res = await fetch(`${API_BASE_URL}/user-devices/${senderDeviceId}/public-key`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch public key for device ${senderDeviceId}`); + } + + const data = (await res.json()) as DevicePublicKey; + keyCache.set(senderDeviceId, data); + return data; +} + +export function getCachedDevicePublicKey(senderDeviceId: string): DevicePublicKey | undefined { + return keyCache.get(senderDeviceId); +} + +export function clearDeviceKeyCache(): void { + keyCache.clear(); +} diff --git a/apps/web/src/lib/crypto/plaintextCache.ts b/apps/web/src/lib/crypto/plaintextCache.ts new file mode 100644 index 0000000..e2283cc --- /dev/null +++ b/apps/web/src/lib/crypto/plaintextCache.ts @@ -0,0 +1,26 @@ +/** + * In-memory cache of decrypted plaintext keyed by messageId. + * Cleared on page unload — no persistence (#185 deferred). + */ + +const cache = new Map(); + +export function getCachedPlaintext(messageId: string): string | undefined { + return cache.get(messageId); +} + +export function setCachedPlaintext(messageId: string, plaintext: string): void { + cache.set(messageId, plaintext); +} + +export function hasCachedPlaintext(messageId: string): boolean { + return cache.has(messageId); +} + +export function clearPlaintextCache(): void { + cache.clear(); +} + +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', clearPlaintextCache); +} diff --git a/apps/web/src/lib/crypto/processEnvelope.ts b/apps/web/src/lib/crypto/processEnvelope.ts new file mode 100644 index 0000000..2d21efc --- /dev/null +++ b/apps/web/src/lib/crypto/processEnvelope.ts @@ -0,0 +1,88 @@ +import { fetchSenderDevicePublicKey } from './deviceKeys'; +import { decryptAndVerifyEnvelope } from './decrypt'; +import { getCachedPlaintext, setCachedPlaintext } from './plaintextCache'; +import { + DecryptError, + PreLinkError, + VerificationFailedError, + type InboundMessage, + type UnavailableReason, +} from './types'; + +export interface EnvelopeInput { + messageId: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + sequenceNumber: number; + contentType: string; + createdAt: string; + ciphertext: string; +} + +function unavailableReasonFromError(err: unknown): UnavailableReason { + if (err instanceof PreLinkError) return 'pre-link'; + if (err instanceof VerificationFailedError) return 'verification-failed'; + if (err instanceof DecryptError) return 'undecryptable'; + return 'undecryptable'; +} + +/** + * Process a single inbound envelope: look up sender key, decrypt, verify, cache. + */ +export async function processInboundEnvelope( + envelope: EnvelopeInput, + token: string, +): Promise { + const base: InboundMessage = { + messageId: envelope.messageId, + conversationId: envelope.conversationId, + senderId: envelope.senderId, + senderDeviceId: envelope.senderDeviceId, + sequenceNumber: envelope.sequenceNumber, + contentType: envelope.contentType, + createdAt: envelope.createdAt, + status: 'pending', + }; + + const cached = getCachedPlaintext(envelope.messageId); + if (cached) { + return { ...base, status: 'decrypted', plaintext: cached }; + } + + if (!envelope.senderDeviceId) { + return { ...base, status: 'unavailable', unavailableReason: 'pre-link' }; + } + + try { + const deviceKey = await fetchSenderDevicePublicKey(envelope.senderDeviceId, token); + const plaintext = await decryptAndVerifyEnvelope( + envelope.ciphertext, + envelope.senderDeviceId, + deviceKey.identityPublicKey, + ); + setCachedPlaintext(envelope.messageId, plaintext); + return { ...base, status: 'decrypted', plaintext }; + } catch (err) { + return { + ...base, + status: 'unavailable', + unavailableReason: unavailableReasonFromError(err), + }; + } +} + +export function sortBySequenceNumber(messages: InboundMessage[]): InboundMessage[] { + return [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber); +} + +export function mergeInboundMessage( + existing: InboundMessage | undefined, + incoming: InboundMessage, +): InboundMessage { + if (!existing) return incoming; + // Prefer a decrypted result over unavailable/pending. + if (existing.status === 'decrypted') return existing; + if (incoming.status === 'decrypted') return { ...existing, ...incoming }; + return { ...existing, ...incoming }; +} diff --git a/apps/web/src/lib/crypto/sessionStore.ts b/apps/web/src/lib/crypto/sessionStore.ts new file mode 100644 index 0000000..68833e0 --- /dev/null +++ b/apps/web/src/lib/crypto/sessionStore.ts @@ -0,0 +1,34 @@ +/** + * In-memory AES session keys keyed by senderDeviceId. + * Populated when a device link / X3DH session is established (outbound path). + */ + +const sessionKeys = new Map(); + +export function getSessionKey(senderDeviceId: string): CryptoKey | undefined { + return sessionKeys.get(senderDeviceId); +} + +export function setSessionKey(senderDeviceId: string, key: CryptoKey): void { + sessionKeys.set(senderDeviceId, key); +} + +export function hasSessionKey(senderDeviceId: string): boolean { + return sessionKeys.has(senderDeviceId); +} + +export function clearSessionKeys(): void { + sessionKeys.clear(); +} + +/** Import a raw 256-bit AES key for a linked sender device. */ +export async function importSessionKey( + senderDeviceId: string, + rawKeyBytes: ArrayBuffer, +): Promise { + const key = await crypto.subtle.importKey('raw', rawKeyBytes, { name: 'AES-GCM' }, false, [ + 'decrypt', + ]); + sessionKeys.set(senderDeviceId, key); + return key; +} diff --git a/apps/web/src/lib/crypto/types.ts b/apps/web/src/lib/crypto/types.ts new file mode 100644 index 0000000..3fa46b2 --- /dev/null +++ b/apps/web/src/lib/crypto/types.ts @@ -0,0 +1,86 @@ +export type UnavailableReason = 'pre-link' | 'undecryptable' | 'verification-failed'; + +export type InboundMessageStatus = 'pending' | 'decrypted' | 'unavailable'; + +/** Live envelope pushed over the socket (#144). */ +export interface MessageEnvelopeEvent { + messageId: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + contentType: string; + sequenceNumber: number; + createdAt: string; + envelopeId: string; + ciphertext: string; +} + +/** Cross-node device delivery — ciphertext only until metadata arrives. */ +export interface DeviceEnvelopeEvent { + messageId: string; + conversationId: string; + ciphertext: string; + sequenceNumber: number; +} + +/** Envelope returned by GET /sync (#137). */ +export interface SyncEnvelope { + id: string; + messageId: string; + conversationId: string; + ciphertext: string; + sequenceNumber: number; + senderId: string; + senderDeviceId: string | null; + contentType: string; + createdAt: string; + messageCreatedAt: string; +} + +/** Parsed ciphertext payload stored in message_envelopes.ciphertext. */ +export interface EncryptedEnvelopePayload { + v: number; + iv: string; + ct: string; + sig?: string; +} + +export interface DevicePublicKey { + id: string; + userId: string; + identityPublicKey: string; +} + +export interface InboundMessage { + messageId: string; + conversationId: string; + senderId: string; + senderDeviceId: string | null; + sequenceNumber: number; + contentType: string; + createdAt: string; + status: InboundMessageStatus; + plaintext?: string; + unavailableReason?: UnavailableReason; +} + +export class PreLinkError extends Error { + constructor() { + super('No session established with sender device'); + this.name = 'PreLinkError'; + } +} + +export class VerificationFailedError extends Error { + constructor() { + super('Envelope signature verification failed'); + this.name = 'VerificationFailedError'; + } +} + +export class DecryptError extends Error { + constructor(message = 'Unable to decrypt envelope') { + super(message); + this.name = 'DecryptError'; + } +} diff --git a/apps/web/src/lib/jwt.ts b/apps/web/src/lib/jwt.ts new file mode 100644 index 0000000..d198c01 --- /dev/null +++ b/apps/web/src/lib/jwt.ts @@ -0,0 +1,35 @@ +export interface JwtPayload { + userId: string; + walletAddress: string; + deviceId: string; +} + +export function parseJwtPayload(token: string): JwtPayload | null { + try { + const [, payload] = token.split('.'); + if (!payload) return null; + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const decoded = JSON.parse(atob(normalized)) as Partial; + if (!decoded.userId || !decoded.deviceId) return null; + return decoded as JwtPayload; + } catch { + return null; + } +} + +const E2E_DEVICE_STORAGE_KEY = 'clicked.e2eDeviceId'; + +/** userDevices.id used for envelope sync — may differ from JWT devices.id. */ +export function getE2EDeviceId(token: string): string | null { + if (typeof window !== 'undefined') { + const stored = window.localStorage.getItem(E2E_DEVICE_STORAGE_KEY); + if (stored) return stored; + } + return parseJwtPayload(token)?.deviceId ?? null; +} + +export function setE2EDeviceId(deviceId: string): void { + if (typeof window !== 'undefined') { + window.localStorage.setItem(E2E_DEVICE_STORAGE_KEY, deviceId); + } +}