initial
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// 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.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface CallEventPayload {
|
||||
v: 1;
|
||||
type: 'call_event';
|
||||
status: CallEventStatus;
|
||||
mediaKind: CallEventKind;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
export type MessagePayload = TextMessagePayload | CallEventPayload;
|
||||
|
||||
export type ParsedMessagePayload =
|
||||
| { kind: 'text'; text: string; attachments: AttachmentHandle[] }
|
||||
| {
|
||||
kind: 'call_event';
|
||||
status: CallEventStatus;
|
||||
mediaKind: CallEventKind;
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
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<MessagePayload> & { 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,
|
||||
};
|
||||
}
|
||||
const t = obj as TextMessagePayload;
|
||||
return {
|
||||
kind: 'text',
|
||||
text: typeof t.text === 'string' ? t.text : '',
|
||||
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
||||
};
|
||||
}
|
||||
} 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<EncryptedAttachmentResult> {
|
||||
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<Blob> {
|
||||
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<void> {
|
||||
const row: Record<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user