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