// 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; } 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 type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload; 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[]; }; 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, }; } 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. export async function encryptAndUploadAttachment(params: { client: AppSupabaseClient; conversationId: string; file: Blob; mimeType: string; sizeBytes: number; width?: number; height?: number; }): 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; 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), }; 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 }); } // 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, }; 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; }