Files
ChatApp/supabase/migrations/20260515000003_user_key_rpcs.sql
byGalax 89eb8d97c6 feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:06:32 +02:00

341 lines
11 KiB
PL/PgSQL

-- RPCs for the per-user key flow. SECURITY DEFINER so the lockout counter
-- is server-authoritative even if a malicious client suppresses
-- record_pin_attempt — try_unlock_user_key refuses to deliver ciphertext
-- while a lockout window is active.
-- 1) try_unlock_user_key ---------------------------------------------------
create or replace function public.try_unlock_user_key(p_user_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
row public.user_keys%rowtype;
begin
if caller is null or caller <> p_user_id then
raise exception 'not authenticated as %', p_user_id;
end if;
select * into row from public.user_keys where user_id = p_user_id;
if not found then
return jsonb_build_object('exists', false);
end if;
if row.locked_until is not null and row.locked_until > now() then
return jsonb_build_object(
'exists', true,
'locked', true,
'locked_until', row.locked_until
);
end if;
return jsonb_build_object(
'exists', true,
'locked', false,
'sealed_private_key', encode(row.sealed_private_key, 'base64'),
'salt', encode(row.salt, 'base64'),
'kdf_params', row.kdf_params,
'recovery_sealed_private_key',
case when row.recovery_sealed_private_key is null
then null
else encode(row.recovery_sealed_private_key, 'base64') end,
'recovery_salt',
case when row.recovery_salt is null
then null
else encode(row.recovery_salt, 'base64') end,
'failed_attempts', row.failed_attempts,
'failed_recovery_attempts', row.failed_recovery_attempts,
'recovery_locked_until', row.recovery_locked_until,
'key_version', row.key_version
);
end;
$$;
revoke execute on function public.try_unlock_user_key(uuid) from public, anon;
grant execute on function public.try_unlock_user_key(uuid) to authenticated;
-- 2) record_pin_attempt ----------------------------------------------------
create or replace function public.record_pin_attempt(
p_user_id uuid,
p_success boolean,
p_recovery boolean default false
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
attempts int;
cooldown interval;
col_attempts text;
col_locked text;
threshold int;
begin
if caller is null or caller <> p_user_id then
raise exception 'not authenticated as %', p_user_id;
end if;
if p_recovery then
col_attempts := 'failed_recovery_attempts';
col_locked := 'recovery_locked_until';
threshold := 20;
else
col_attempts := 'failed_attempts';
col_locked := 'locked_until';
threshold := 10;
end if;
if p_success then
execute format(
'update public.user_keys set %I = 0, %I = null, updated_at = now() where user_id = $1',
col_attempts, col_locked
) using p_user_id;
return jsonb_build_object('failed_attempts', 0, 'locked_until', null);
end if;
execute format(
'update public.user_keys set %I = %I + 1, updated_at = now() where user_id = $1 returning %I',
col_attempts, col_attempts, col_attempts
) using p_user_id into attempts;
cooldown := case
when attempts < 5 then interval '0 second'
when attempts = 5 then interval '5 second'
when attempts = 6 then interval '30 second'
when attempts = 7 then interval '2 minute'
when attempts = 8 then interval '10 minute'
when attempts = 9 then interval '1 hour'
when attempts >= threshold and not p_recovery
then interval '24 hour'
when attempts >= threshold and p_recovery
then interval '100 year'
else interval '0 second'
end;
if cooldown > interval '0 second' then
execute format(
'update public.user_keys set %I = now() + $2 where user_id = $1',
col_locked
) using p_user_id, cooldown;
end if;
return jsonb_build_object(
'failed_attempts', attempts,
'locked_until',
case when cooldown > interval '0 second' then now() + cooldown else null end
);
end;
$$;
revoke execute on function public.record_pin_attempt(uuid, boolean, boolean) from public, anon;
grant execute on function public.record_pin_attempt(uuid, boolean, boolean) to authenticated;
-- 3) share_conv_keys: now accepts recipient_user_id in each bundle --------
drop function if exists public.share_conv_keys(uuid, uuid, int, jsonb);
create or replace function public.share_conv_keys(
p_conv_id uuid,
p_sender_device_id uuid, -- legacy; nullable if p_sender_user_id supplied
p_sender_user_id uuid, -- new
p_key_version int,
p_bundles jsonb
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
bundle jsonb;
inserted int := 0;
recipient_uid uuid;
recipient_did uuid;
enc_key_hex text;
nonce_hex text;
begin
if caller is null then raise exception 'not authenticated'; 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;
if p_sender_user_id is not null and p_sender_user_id <> caller then
raise exception 'sender_user_id mismatch';
end if;
if p_sender_device_id is not null and not exists (
select 1 from public.devices where id = p_sender_device_id and user_id = caller
) then
raise exception 'sender_device % not owned by caller', p_sender_device_id;
end if;
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';
if recipient_uid is null and recipient_did is not null then
select user_id into recipient_uid from public.devices where id = recipient_did;
end if;
if recipient_uid is null then continue; end if;
if not exists (
select 1
from public.conversation_members
where conversation_id = p_conv_id
and user_id = recipient_uid
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_key_version, p_sender_user_id, p_sender_device_id,
decode(enc_key_hex, 'hex'),
decode(nonce_hex, 'hex'))
on conflict (conversation_id, recipient_user_id, key_version)
where recipient_user_id is not null
do nothing;
if found then inserted := inserted + 1; end if;
end loop;
return inserted;
end;
$$;
revoke execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) from public, anon;
grant execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) to authenticated;
-- 4) reset_user_key: hard wipe + replace -----------------------------------
create or replace function public.reset_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 int
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
rows_deleted int;
begin
if caller is null or caller <> p_user_id then
raise exception 'not authenticated as %', p_user_id;
end if;
delete from public.conversation_keys
where recipient_user_id = p_user_id
or recipient_device_id in (select id from public.devices where user_id = p_user_id);
get diagnostics rows_deleted = row_count;
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,
key_version = public.user_keys.key_version + 1,
updated_at = now();
return rows_deleted;
end;
$$;
revoke execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
grant execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;
-- 5) migrate_user_key_recipients ------------------------------------------
create or replace function public.migrate_user_key_recipients(
p_conv_id uuid,
p_user_id uuid,
p_key_version int,
p_bundles jsonb -- [{ encrypted_key: hex, nonce: hex, sender_user_id: uuid }]
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
bundle jsonb;
inserted int := 0;
begin
if caller is null or caller <> p_user_id then
raise exception 'not authenticated as %', p_user_id;
end if;
if not exists (
select 1 from public.conversation_members
where conversation_id = p_conv_id and user_id = p_user_id and accepted = true
) then
raise exception 'not a member';
end if;
for bundle in select * from jsonb_array_elements(p_bundles) loop
insert into public.conversation_keys (
conversation_id, recipient_user_id, key_version,
sender_user_id, encrypted_key, nonce
) values (
p_conv_id, p_user_id, p_key_version,
nullif(bundle->>'sender_user_id', '')::uuid,
decode(bundle->>'encrypted_key', 'hex'),
decode(bundle->>'nonce', 'hex')
)
on conflict (conversation_id, recipient_user_id, key_version)
where recipient_user_id is not null
do nothing;
if found then inserted := inserted + 1; end if;
end loop;
return inserted;
end;
$$;
revoke execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) from public, anon;
grant execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) to authenticated;