146 lines
5.4 KiB
SQL
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;
|