-- Sender-Key (Signal-style) multi-device crypto. -- -- Replaces per-device envelopes with a per-conversation symmetric key. -- Each device that should be able to read a conversation gets one row in -- `conversation_keys` containing the conv-key wrapped to its X25519 pubkey. -- -- Senders encrypt the message body once with the conv-key (XSalsa20-Poly1305 -- secretbox) instead of N times to N device pubkeys. This makes new-device -- onboarding for existing conversations possible: any existing device can -- wrap the conv-key for a freshly-registered device, and that device can then -- decrypt the entire history without back-population. -- -- Pre-launch wipe: existing messages + envelopes are dropped (the old -- per-device envelopes can't be re-encoded into the new format without the -- sender's private key, which lives only on each user's device). -- 1) Wipe legacy message data -------------------------------------------------- drop table if exists public.message_envelopes cascade; truncate table public.message_reactions, public.message_reads, public.message_attachments, public.messages restart identity cascade; -- 2) Add ciphertext + nonce + key_version columns to messages ----------------- alter table public.messages add column if not exists ciphertext bytea, add column if not exists nonce bytea, add column if not exists key_version int; -- Backfill not needed because we just truncated. Make NOT NULL going forward. alter table public.messages alter column ciphertext set not null, alter column nonce set not null, alter column key_version set not null, alter column key_version set default 1; -- 3) Track currently-active key version per conversation ---------------------- alter table public.conversations add column if not exists active_key_version int not null default 1; -- 4) conversation_keys table --------------------------------------------------- -- -- One row per (conversation, recipient_device, key_version). The wrapped key -- is encrypted with `crypto_box`/`box`-style asymmetric crypto (X25519 + -- XSalsa20-Poly1305) — sender_device's private key + recipient_device's -- public key derive a shared secret to decrypt the embedded conv-key. create table if not exists public.conversation_keys ( conversation_id uuid not null references public.conversations(id) on delete cascade, recipient_device_id uuid not null references public.devices(id) on delete cascade, key_version int not null, sender_device_id uuid not null references public.devices(id) on delete restrict, encrypted_key bytea not null, nonce bytea not null, created_at timestamptz not null default now(), primary key (conversation_id, recipient_device_id, key_version) ); create index if not exists conversation_keys_recipient_idx on public.conversation_keys(recipient_device_id); create index if not exists conversation_keys_conv_version_idx on public.conversation_keys(conversation_id, key_version); -- 5) RLS policies for conversation_keys --------------------------------------- alter table public.conversation_keys enable row level security; -- A user can SELECT a row only if the recipient device belongs to them. drop policy if exists conversation_keys_select_owner on public.conversation_keys; create policy conversation_keys_select_owner on public.conversation_keys for select to authenticated using ( exists ( select 1 from public.devices d where d.id = recipient_device_id and d.user_id = auth.uid() ) ); -- INSERT allowed only by an authenticated user who: -- (a) is a member of the conversation, -- (b) owns the sender_device_id (so a 3rd party can't impersonate), -- (c) the recipient_device belongs to a member of the same conversation. drop policy if exists conversation_keys_insert_member on public.conversation_keys; create policy conversation_keys_insert_member on public.conversation_keys for insert to authenticated with check ( exists ( select 1 from public.conversation_members m where m.conversation_id = conversation_keys.conversation_id and m.user_id = auth.uid() and m.accepted = true ) and exists ( select 1 from public.devices d where d.id = sender_device_id and d.user_id = auth.uid() ) and exists ( select 1 from public.devices d join public.conversation_members m on m.user_id = d.user_id and m.conversation_id = conversation_keys.conversation_id where d.id = recipient_device_id and m.accepted = true ) ); -- DELETE: only sender (cleanup, e.g. when a device is removed). Rare. drop policy if exists conversation_keys_delete_sender on public.conversation_keys; create policy conversation_keys_delete_sender on public.conversation_keys for delete to authenticated using ( exists ( select 1 from public.devices d where d.id = sender_device_id and d.user_id = auth.uid() ) ); -- 6) Realtime publication ------------------------------------------------------ -- -- Devices subscribe to INSERTs on `conversation_keys` so a freshly-registered -- device sees its key bundles arrive. They also already subscribe to `devices` -- INSERTs to know when peers join a conversation; that's set up in earlier -- migrations. alter publication supabase_realtime add table public.conversation_keys;