fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs
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.
This commit is contained in:
@@ -416,7 +416,11 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
<span className="italic text-fg-muted opacity-60">
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
|
||||
expect(res.lockedUntil).toBe(lockedUntil);
|
||||
});
|
||||
|
||||
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
|
||||
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
|
||||
it('uploadUserKeyBlob upserts via upsert_user_key RPC (non-destructive)', async () => {
|
||||
mock.setRpcResponse('upsert_user_key', { data: null, error: null });
|
||||
await uploadUserKeyBlob(mock.client, {
|
||||
userId: USER_ID,
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
@@ -64,7 +64,9 @@ describe('auth/userKey', () => {
|
||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
||||
});
|
||||
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
||||
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
|
||||
// Crucial: must hit upsert_user_key, NOT reset_user_key — the latter
|
||||
// deletes every conversation_keys row for the user (0.18.0–0.18.2 bug).
|
||||
expect(mock.rpcCalls.at(-1)?.name).toBe('upsert_user_key');
|
||||
expect(params.p_user_id).toBe(USER_ID);
|
||||
expect(params.p_public_key_b64).toBe('AQID');
|
||||
expect(params.p_sealed_private_b64).toBe('BAU=');
|
||||
|
||||
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
|
||||
client: AppSupabaseClient,
|
||||
params: UploadParams,
|
||||
): Promise<void> {
|
||||
const { error } = await rpc(client).rpc('reset_user_key', {
|
||||
// Non-destructive UPSERT — must NOT touch conversation_keys. Used for the
|
||||
// first-time PIN setup, PIN change, and recovery-code regeneration. The
|
||||
// 0.18.0–0.18.2 builds wired this to `reset_user_key` which DELETED every
|
||||
// legacy conv-key bundle for the user before the migration could re-wrap
|
||||
// them, leaving people unable to read or send. `upsert_user_key` writes
|
||||
// only the user_keys row and leaves conversation_keys alone.
|
||||
const { error } = await rpc(client).rpc('upsert_user_key', {
|
||||
p_user_id: params.userId,
|
||||
p_public_key_b64: bytesToB64(params.publicKey),
|
||||
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
||||
|
||||
@@ -138,11 +138,105 @@ export async function getOrCreateConvKey(
|
||||
.eq('key_version', version);
|
||||
if (cntErr) throw cntErr;
|
||||
if ((count ?? 0) > 0) {
|
||||
throw new Error('Awaiting conversation key — another user must share it with this user.');
|
||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
||||
// that originally received my bundle, or my own bundle was wiped by the
|
||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
||||
// unreadable for me; new ones flow.
|
||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
||||
return rotateConvKey(client, conversationId, own);
|
||||
}
|
||||
return bootstrapConvKey(client, conversationId, own, version);
|
||||
}
|
||||
|
||||
// Mints a fresh conv-key at active_key_version + 1 and wraps it for every
|
||||
// accepted member. Per-user bundles take priority; for members lacking a
|
||||
// user_keys row we fall back to per-device wrapping (one bundle per device)
|
||||
// so peers still on the legacy 0.17.x client can decrypt with their device
|
||||
// private key. Caller must own a copy of their private key in `own`.
|
||||
export async function rotateConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnUserCtx,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const currentVersion = await fetchActiveKeyVersion(client, conversationId);
|
||||
const newVersion = currentVersion + 1;
|
||||
|
||||
const { data: members, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr) throw mErr;
|
||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||
if (memberIds.length === 0) throw new Error('cannot rotate — no accepted members');
|
||||
|
||||
const userKeys = await fetchPeerPublicKeys(client, memberIds);
|
||||
const userKeyByUserId = new Map(userKeys.map((k) => [k.userId, k.publicKey]));
|
||||
const missingUserKeyMembers = memberIds.filter((id) => !userKeyByUserId.has(id));
|
||||
|
||||
let legacyDevices: { userId: string; deviceId: string; publicKey: Uint8Array }[] = [];
|
||||
if (missingUserKeyMembers.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: devs, error: dErr } = await (client as any)
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', missingUserKeyMembers)
|
||||
.not('public_key', 'is', null);
|
||||
if (dErr) throw dErr;
|
||||
legacyDevices = (devs ?? []).map((d: { id: string; user_id: string; public_key: string }) => ({
|
||||
userId: d.user_id,
|
||||
deviceId: d.id,
|
||||
publicKey: pgHexToBytes(d.public_key),
|
||||
}));
|
||||
}
|
||||
|
||||
const convKey = generateConvKey();
|
||||
const bundles: Array<{
|
||||
recipient_user_id?: string;
|
||||
recipient_device_id?: string;
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
}> = [];
|
||||
for (const k of userKeys) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, k.publicKey, own.privateKey);
|
||||
bundles.push({
|
||||
recipient_user_id: k.userId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
for (const d of legacyDevices) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, d.publicKey, own.privateKey);
|
||||
bundles.push({
|
||||
recipient_device_id: d.deviceId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
if (bundles.length === 0) {
|
||||
throw new Error('cannot rotate — no peers have a public key (no user_keys, no devices)');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||
const { error } = await rpc.call(client, 'rotate_conv_key', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_user_id: own.userId,
|
||||
p_new_version: newVersion,
|
||||
p_bundles: bundles,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle = { conversationId, keyVersion: newVersion, key: convKey };
|
||||
cache.set(cacheKey(conversationId, newVersion), handle);
|
||||
console.info(
|
||||
'[conv-key] rotated conversation ' + conversationId.slice(0, 8) +
|
||||
' from v' + currentVersion + ' to v' + newVersion +
|
||||
' — wrapped for ' + userKeys.length + ' user-keys + ' + legacyDevices.length + ' legacy devices',
|
||||
);
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function tryGetConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user