Files
ChatApp/supabase/migrations/20260419000001_sender_keys.sql
T
byGalax 75618637e2
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat(crypto): sender-key per-conversation multi-device E2EE
2026-04-19 19:27:24 +02:00

146 lines
5.4 KiB
SQL

-- 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;