d39a0fb6dc
Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben":
uploadUserKeyBlob (called by setupNewUserIdentity, changePin and
regenerateRecoveryCode) routed through reset_user_key, which DELETES
every conversation_keys row addressed to the user or one of their
devices. So setting a PIN destroyed every legacy bundle BEFORE the
migration could re-wrap them. The user ended up with user_keys set,
zero un-migrated bundles, no decryption, no send.
Fixes shipped:
* supabase/migrations/20260516000001_user_key_rpcs_v2.sql
- upsert_user_key: same UPSERT, NO delete. Used everywhere except
"Identität zurücksetzen" (which keeps reset_user_key on purpose).
- rotate_conv_key: bumps active_key_version atomically and inserts
a fresh batch of bundles (per-user + per-device fallback).
* shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key.
* shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh
conv-key for every member's user_keys (preferred) and falls back to
each member's per-device public_key for peers still on 0.17.x.
* shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate
when the user has no recipient_user_id row at the active version
but rows exist (the deadlock case). Existing outbox retries drain
on their own once the rotate completes — no manual button.
* desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer,
German "Nachricht nicht lesbar" so users don't think the app
crashed when historical messages can't be unwrapped.
174 lines
6.3 KiB
PL/PgSQL
174 lines
6.3 KiB
PL/PgSQL
-- 0.18.3 hotfix: split user_keys upload into a non-destructive `upsert_user_key`
|
|
-- and the existing destructive `reset_user_key`. Add `rotate_conv_key` so the
|
|
-- client can escape "Awaiting key" deadlocks by minting a fresh per-conversation
|
|
-- key and wrapping it for every member (per-user where possible, per-device as
|
|
-- a legacy fallback for peers still on 0.17.x).
|
|
--
|
|
-- Why: `reset_user_key` was being called from EVERY upload path
|
|
-- (setupNewUserIdentity, changePin, regenerateRecoveryCode), wiping every
|
|
-- legacy `conversation_keys` row for the user before the migration could
|
|
-- re-wrap them. Users ended up with `user_keys` set, zero un-migrated
|
|
-- bundles, and no way to send or read.
|
|
|
|
-- 1) upsert_user_key — same UPSERT as reset_user_key but WITHOUT the DELETE.
|
|
-- Safe to call on every PIN-set / PIN-change / recovery-regen.
|
|
|
|
create or replace function public.upsert_user_key(
|
|
p_user_id uuid,
|
|
p_public_key_b64 text,
|
|
p_sealed_private_b64 text,
|
|
p_salt_b64 text,
|
|
p_kdf_params jsonb,
|
|
p_recovery_sealed_b64 text default null,
|
|
p_recovery_salt_b64 text default null
|
|
) returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
caller uuid := auth.uid();
|
|
begin
|
|
if caller is null or caller <> p_user_id then
|
|
raise exception 'not authenticated as %', p_user_id;
|
|
end if;
|
|
|
|
insert into public.user_keys (
|
|
user_id, public_key, sealed_private_key, salt, kdf_params,
|
|
recovery_sealed_private_key, recovery_salt,
|
|
failed_attempts, locked_until,
|
|
failed_recovery_attempts, recovery_locked_until,
|
|
key_version, created_at, updated_at
|
|
) values (
|
|
p_user_id,
|
|
decode(p_public_key_b64, 'base64'),
|
|
decode(p_sealed_private_b64, 'base64'),
|
|
decode(p_salt_b64, 'base64'),
|
|
p_kdf_params,
|
|
case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
|
|
case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end,
|
|
0, null, 0, null,
|
|
1, now(), now()
|
|
)
|
|
on conflict (user_id) do update set
|
|
public_key = excluded.public_key,
|
|
sealed_private_key = excluded.sealed_private_key,
|
|
salt = excluded.salt,
|
|
kdf_params = excluded.kdf_params,
|
|
recovery_sealed_private_key = excluded.recovery_sealed_private_key,
|
|
recovery_salt = excluded.recovery_salt,
|
|
failed_attempts = 0,
|
|
locked_until = null,
|
|
failed_recovery_attempts = 0,
|
|
recovery_locked_until = null,
|
|
-- Don't bump key_version here — the public key is unchanged.
|
|
updated_at = now();
|
|
end;
|
|
$$;
|
|
|
|
revoke execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
|
|
grant execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;
|
|
|
|
-- 2) rotate_conv_key — atomically bumps active_key_version and inserts a new
|
|
-- set of bundles. Each bundle may carry recipient_user_id (per-user wrap)
|
|
-- OR recipient_device_id (per-device fallback for peers on the legacy
|
|
-- client). Caller must ensure the new version is strictly greater than
|
|
-- the current one (we lock the row to prevent races).
|
|
|
|
create or replace function public.rotate_conv_key(
|
|
p_conv_id uuid,
|
|
p_sender_user_id uuid,
|
|
p_new_version int,
|
|
p_bundles jsonb
|
|
) returns int
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
caller uuid := auth.uid();
|
|
cur_version int;
|
|
bundle jsonb;
|
|
inserted int := 0;
|
|
recipient_uid uuid;
|
|
recipient_did uuid;
|
|
member_user_id uuid;
|
|
enc_key_hex text;
|
|
nonce_hex text;
|
|
begin
|
|
if caller is null or caller <> p_sender_user_id then
|
|
raise exception 'not authenticated as %', p_sender_user_id;
|
|
end if;
|
|
|
|
if not exists (
|
|
select 1 from public.conversation_members
|
|
where conversation_id = p_conv_id
|
|
and user_id = caller
|
|
and accepted = true
|
|
) then
|
|
raise exception 'caller is not an accepted member of %', p_conv_id;
|
|
end if;
|
|
|
|
-- Lock the conversation row so concurrent rotations don't race the version bump.
|
|
select active_key_version into cur_version
|
|
from public.conversations
|
|
where id = p_conv_id
|
|
for update;
|
|
if cur_version is null then
|
|
raise exception 'conversation % not found', p_conv_id;
|
|
end if;
|
|
if p_new_version <= cur_version then
|
|
raise exception 'new key version % must be greater than current %',
|
|
p_new_version, cur_version;
|
|
end if;
|
|
|
|
update public.conversations
|
|
set active_key_version = p_new_version
|
|
where id = p_conv_id;
|
|
|
|
-- Insert each bundle. We don't auto-derive recipient_user_id from the
|
|
-- device anymore — for per-device fallback rows the column stays NULL so
|
|
-- multiple devices of the same user can each get their own bundle.
|
|
for bundle in select * from jsonb_array_elements(p_bundles) loop
|
|
recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
|
|
recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
|
|
enc_key_hex := bundle->>'encrypted_key';
|
|
nonce_hex := bundle->>'nonce';
|
|
|
|
-- Validate membership regardless of mode.
|
|
if recipient_uid is not null then
|
|
member_user_id := recipient_uid;
|
|
elsif recipient_did is not null then
|
|
select user_id into member_user_id from public.devices where id = recipient_did;
|
|
else
|
|
continue;
|
|
end if;
|
|
if member_user_id is null then continue; end if;
|
|
if not exists (
|
|
select 1 from public.conversation_members
|
|
where conversation_id = p_conv_id
|
|
and user_id = member_user_id
|
|
and accepted = true
|
|
) then continue; end if;
|
|
|
|
insert into public.conversation_keys
|
|
(conversation_id, recipient_user_id, recipient_device_id,
|
|
key_version, sender_user_id, sender_device_id,
|
|
encrypted_key, nonce)
|
|
values
|
|
(p_conv_id, recipient_uid, recipient_did,
|
|
p_new_version, p_sender_user_id, null,
|
|
decode(enc_key_hex, 'hex'),
|
|
decode(nonce_hex, 'hex'))
|
|
on conflict do nothing;
|
|
|
|
if found then inserted := inserted + 1; end if;
|
|
end loop;
|
|
|
|
return inserted;
|
|
end;
|
|
$$;
|
|
|
|
revoke execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) from public, anon;
|
|
grant execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) to authenticated;
|