diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx
index 5f2a234..16d0a2f 100644
--- a/apps/desktop/src/components/MessageBubble.tsx
+++ b/apps/desktop/src/components/MessageBubble.tsx
@@ -416,7 +416,11 @@ export function MessageBubble({
)}
{message.plaintext === null ? (
- …cannot decrypt
+
+ {t('app:chats.unreadable', {
+ defaultValue: 'Nachricht nicht lesbar',
+ })}
+
) : parsed.kind === 'poll' ? (
{
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;
- 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=');
diff --git a/packages/shared/src/auth/userKey.ts b/packages/shared/src/auth/userKey.ts
index e85a300..55ab9d9 100644
--- a/packages/shared/src/auth/userKey.ts
+++ b/packages/shared/src/auth/userKey.ts
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise {
- 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),
diff --git a/packages/shared/src/chat/convKeys.ts b/packages/shared/src/chat/convKeys.ts
index 47ecece..29e3901 100644
--- a/packages/shared/src/chat/convKeys.ts
+++ b/packages/shared/src/chat/convKeys.ts
@@ -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 {
+ 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,
diff --git a/supabase/migrations/20260516000001_user_key_rpcs_v2.sql b/supabase/migrations/20260516000001_user_key_rpcs_v2.sql
new file mode 100644
index 0000000..7632229
--- /dev/null
+++ b/supabase/migrations/20260516000001_user_key_rpcs_v2.sql
@@ -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;