// Encrypted attachment upload / download. // // Per-message symmetric key + nonce encrypts the raw blob (XSalsa20-Poly1305 // via secretbox). The encrypted blob is uploaded to Supabase Storage under // `{conversation_id}/{attachment_id}.bin`. The symmetric key + blob-nonce // travel inside the per-device message envelope as JSON, so the server never // sees the decryption material. import { fromBase64, getCryptoBackend, toBase64 } from '../crypto/index'; import type { AppSupabaseClient } from '../supabase/client'; export const ATTACHMENT_BUCKET = 'chat-attachments'; export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB // The per-attachment envelope material + public metadata. The base64 fields // live in the encrypted message payload; the storage path + mime live on // the public message_attachments row. export interface AttachmentHandle { id: string; // matches message_attachments.id + storage sub-path storagePath: string; mimeType: string; sizeBytes: number; width?: number; height?: number; // base64-encoded — only readable via per-device envelope decrypt. keyB64: string; nonceB64: string; /** View-once flag — set by sender on the encrypted envelope and mirrored * to `message_attachments.view_once` so the renderer can show a "tap to * view" tombstone and call the mark-viewed RPC on first open. */ viewOnce?: boolean; /** ISO timestamp the first non-sender opened the attachment. Populated by * the mark-viewed RPC (server-authoritative); undefined until burnt. */ viewedAt?: string | null; /** UUID of the first non-sender who viewed. Populated alongside `viewedAt` * by the mark-viewed RPC so the renderer can render attribution. */ viewedBy?: string | null; /** Storage path of the encrypted WebP preview thumb (max 320×320). The * thumb shares the per-attachment symmetric key with the full blob but * uses its own nonce. Absent on pre-Phase-6B messages — the receiver * falls through to downloading the full blob in that case. */ thumbStoragePath?: string; /** Base-64 nonce that decrypts `-thumb.bin`. Always present iff * `thumbStoragePath` is set. */ thumbNonceB64?: string; } export type CallEventStatus = 'ended' | 'missed' | 'declined'; export type CallEventKind = 'audio' | 'video'; // Plaintext payload wire format: either text+attachments or a compact // call-event record. Pre-attachment messages without JSON auto-upgrade // via the fallback branch in parseMessagePayload. export interface TextMessagePayload { v: 1; type?: 'text'; text: string; attachments: AttachmentHandle[]; /** When set, the message self-destructs `expireMs` milliseconds after the * server's createdAt. Sender initiates the delete; receivers hide locally * as soon as the window elapses. Added in /tempmsg support. */ expireMs?: number; } export interface CallEventPayload { v: 1; type: 'call_event'; status: CallEventStatus; mediaKind: CallEventKind; durationSec: number; } export interface PollOption { id: string; emoji: string; text: string; } export interface PollPayload { v: 1; type: 'poll'; question: string; options: PollOption[]; } export interface WhiteboardPayload { v: 1; type: 'whiteboard'; whiteboard_id: string; } export interface WatchTogetherPayload { v: 1; type: 'watch_together'; session_id: string; } export interface GamePayload { v: 1; type: 'game'; game_id: string; game_type: 'ttt' | 'c4'; } export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload | WhiteboardPayload | WatchTogetherPayload | GamePayload; export type ParsedMessagePayload = | { kind: 'text'; text: string; attachments: AttachmentHandle[]; expireMs?: number; } | { kind: 'call_event'; status: CallEventStatus; mediaKind: CallEventKind; durationSec: number; } | { kind: 'poll'; question: string; options: PollOption[]; } | { kind: 'whiteboard'; whiteboardId: string; } | { kind: 'watch_together'; sessionId: string; } | { kind: 'game'; gameId: string; gameType: 'ttt' | 'c4'; }; export function serializeMessagePayload(payload: MessagePayload): string { if ( (!('type' in payload) || payload.type === 'text' || payload.type === undefined) && 'attachments' in payload && payload.attachments.length === 0 ) { return payload.text; } return JSON.stringify(payload); } export function parseMessagePayload(raw: string | null): ParsedMessagePayload { if (!raw) return { kind: 'text', text: '', attachments: [] }; if (!raw.startsWith('{')) return { kind: 'text', text: raw, attachments: [] }; try { const obj = JSON.parse(raw) as Partial & { type?: string }; if (obj && obj.v === 1) { if (obj.type === 'call_event') { const p = obj as CallEventPayload; return { kind: 'call_event', status: p.status, mediaKind: p.mediaKind, durationSec: typeof p.durationSec === 'number' ? p.durationSec : 0, }; } if (obj.type === 'poll') { const p = obj as Partial; const options = Array.isArray(p.options) ? p.options .map((option, idx) => { const candidate = option as Partial; const text = typeof candidate.text === 'string' ? candidate.text.trim() : ''; if (!text) return null; return { id: typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id.trim() : 'option-' + (idx + 1), emoji: typeof candidate.emoji === 'string' && candidate.emoji.trim() ? candidate.emoji.trim() : String(idx + 1), text, }; }) .filter((option): option is PollOption => option !== null) : []; return { kind: 'poll', question: typeof p.question === 'string' ? p.question.trim() : '', options, }; } if (obj.type === 'whiteboard') { const p = obj as Partial; const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0 ? p.whiteboard_id : ''; return { kind: 'whiteboard', whiteboardId: id }; } if (obj.type === 'watch_together') { const p = obj as Partial; const id = typeof p.session_id === 'string' && p.session_id.length > 0 ? p.session_id : ''; return { kind: 'watch_together', sessionId: id }; } if (obj.type === 'game') { const p = obj as Partial; const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : ''; const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt'; return { kind: 'game', gameId: id, gameType: t }; } const t = obj as TextMessagePayload; return { kind: 'text', text: typeof t.text === 'string' ? t.text : '', attachments: Array.isArray(t.attachments) ? t.attachments : [], ...(typeof t.expireMs === 'number' && t.expireMs > 0 ? { expireMs: t.expireMs } : {}), }; } } catch { /* fall through to plain text */ } return { kind: 'text', text: raw, attachments: [] }; } function ensureUuid(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } throw new Error('crypto.randomUUID unavailable'); } export interface EncryptedAttachmentResult { handle: AttachmentHandle; key: Uint8Array; // raw bytes — caller is responsible for wiping nonce: Uint8Array; } // Encrypt + upload a blob. Does NOT insert the message_attachments row — // the caller combines this with a message insert so everything commits // atomically at the application layer. // // When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second // ciphertext is uploaded to `/-thumb.bin` encrypted // with the SAME per-attachment key + a fresh nonce. The handle's // `thumbStoragePath` / `thumbNonceB64` get populated so the receiver // can prefer the thumb for inline preview. Thumb-upload failure is // non-fatal — we log and fall through so the full image still posts. export async function encryptAndUploadAttachment(params: { client: AppSupabaseClient; conversationId: string; file: Blob; mimeType: string; sizeBytes: number; width?: number; height?: number; thumbBlob?: Blob | null; }): Promise { const backend = getCryptoBackend(); const bytes = new Uint8Array(await params.file.arrayBuffer()); const key = backend.randomBytes(backend.secretboxKeyLength); const nonce = backend.randomBytes(backend.secretboxNonceLength); const ciphertext = backend.secretbox(bytes, nonce, key); const id = ensureUuid(); const storagePath = params.conversationId + '/' + id + '.bin'; const { error } = await params.client.storage .from(ATTACHMENT_BUCKET) .upload(storagePath, ciphertext, { contentType: 'application/octet-stream', upsert: false, }); if (error) throw error; let thumbStoragePath: string | undefined; let thumbNonceB64: string | undefined; if (params.thumbBlob) { try { const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer()); const thumbNonce = backend.randomBytes(backend.secretboxNonceLength); const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key); const thumbPath = params.conversationId + '/' + id + '-thumb.bin'; const { error: thumbErr } = await params.client.storage .from(ATTACHMENT_BUCKET) .upload(thumbPath, thumbCipher, { contentType: 'application/octet-stream', upsert: false, }); if (thumbErr) { // Non-fatal: log and continue with full-only handle. Receiver will // fall back to fetching the full blob. console.warn('thumb upload failed', thumbErr); } else { thumbStoragePath = thumbPath; thumbNonceB64 = await toBase64(thumbNonce); } // Wipe nonce buffer. for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0; } catch (err: unknown) { console.warn('thumb encrypt/upload failed', err); } } const handle: AttachmentHandle = { id, storagePath, mimeType: params.mimeType, sizeBytes: params.sizeBytes, ...(params.width !== undefined ? { width: params.width } : {}), ...(params.height !== undefined ? { height: params.height } : {}), keyB64: await toBase64(key), nonceB64: await toBase64(nonce), ...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}), ...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}), }; return { handle, key, nonce }; } // Download + decrypt an attachment blob and return a Blob the caller can use // with URL.createObjectURL. Throws on network or authentication failures. export async function downloadAndDecryptAttachment(params: { client: AppSupabaseClient; handle: AttachmentHandle; }): Promise { const backend = getCryptoBackend(); const { data, error } = await params.client.storage .from(ATTACHMENT_BUCKET) .download(params.handle.storagePath); if (error) throw error; if (!data) throw new Error('empty download'); const ciphertext = new Uint8Array(await data.arrayBuffer()); const key = await fromBase64(params.handle.keyB64); const nonce = await fromBase64(params.handle.nonceB64); const plainBytes = backend.secretboxOpen(ciphertext, nonce, key); // Zero key/nonce buffers on the way out. for (let i = 0; i < key.length; i++) key[i] = 0; for (let i = 0; i < nonce.length; i++) nonce[i] = 0; // Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants. const copy = new Uint8Array(plainBytes.byteLength); copy.set(plainBytes); return new Blob([copy.buffer], { type: params.handle.mimeType }); } // Download + decrypt the small WebP preview thumb that the sender uploaded // alongside an image attachment (Phase 6B optimisation). Returns `null` if // the handle has no thumb metadata (pre-Phase-6B message) or if the thumb // blob is missing from storage — caller falls back to the full image. // // We deliberately swallow ANY download error (missing object, transient // 5xx) so the receiver gracefully degrades to the full-blob path; only a // successful decrypt-failure throws, since that signals a real corruption. export async function downloadAndDecryptAttachmentThumb(params: { client: AppSupabaseClient; handle: AttachmentHandle; }): Promise { if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) { return null; } const backend = getCryptoBackend(); let data: Blob | null = null; try { const res = await params.client.storage .from(ATTACHMENT_BUCKET) .download(params.handle.thumbStoragePath); if (res.error) { // Likely 404 — sender failed to upload thumb, or it's been GC'd. // Receiver falls back to full image. return null; } data = res.data; } catch { return null; } if (!data) return null; const ciphertext = new Uint8Array(await data.arrayBuffer()); const key = await fromBase64(params.handle.keyB64); const nonce = await fromBase64(params.handle.thumbNonceB64); const plainBytes = backend.secretboxOpen(ciphertext, nonce, key); for (let i = 0; i < key.length; i++) key[i] = 0; for (let i = 0; i < nonce.length; i++) nonce[i] = 0; const copy = new Uint8Array(plainBytes.byteLength); copy.set(plainBytes); // Thumbs are always image/webp regardless of original mime. return new Blob([copy.buffer], { type: 'image/webp' }); } // Insert the public metadata row for an attachment. The ciphertext itself has // already been uploaded to storage under `handle.storagePath`. export async function insertAttachmentRow( client: AppSupabaseClient, messageId: string, handle: AttachmentHandle, blobNonceHex: string, ): Promise { const row: Record = { id: handle.id, message_id: messageId, storage_path: handle.storagePath, nonce: blobNonceHex, mime_type: handle.mimeType, size_bytes: handle.sizeBytes, view_once: handle.viewOnce ?? false, }; if (handle.width !== undefined) row.width = handle.width; if (handle.height !== undefined) row.height = handle.height; const { error } = await client.from('message_attachments').insert(row as never); if (error) throw error; }