Files
ChatApp/packages/shared/src/chat/userKeyMigration.ts
T
byGalax 6caa674c19 fix(shared): legacy conv-key migration query used .eq(null) instead of .is(null)
PostgREST translates .eq('col', null) to `col = NULL` which is always false
in SQL. The migration silently returned zero rows -> setupNewUserIdentity
fired but re-wrapped nothing -> users could set a PIN but every send threw
'Awaiting key'. Switching to .is('col', null) emits `col IS NULL` and the
migration finally finds its work.

Also makes the migration trigger idempotent and re-fires it on:
  - every successful loadOrUnlockUserKey
  - AuthContext startup when the user-key is already cached
so users stuck on 0.18.0 auto-recover the moment they install 0.18.1.

PinInput: focused + active-slot now show a brand-coloured ring, glow, and
a blinking caret so users see where the next keystroke lands.
2026-05-16 00:15:15 +02:00

117 lines
4.4 KiB
TypeScript

import { decryptFrom, encryptFor } from '../crypto/box';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
import type { AppSupabaseClient } from '../supabase/client';
export interface MigrateParams {
client: AppSupabaseClient;
ownUserId: string;
ownNewPublicKey: Uint8Array;
ownNewPrivateKey: Uint8Array;
ownLegacyDeviceIds: string[];
ownLegacyDevicePrivateKeys: Record<string, Uint8Array>;
}
export interface MigrateResult {
migratedConversations: number;
errors: { conversationId: string; reason: string }[];
}
interface LegacyRow {
conversation_id: string;
key_version: number;
recipient_device_id: string;
sender_device_id: string;
sender_user_id: string | null;
encrypted_key: string;
nonce: string;
}
function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); }
async function fetchSenderPubKeys(
client: AppSupabaseClient,
ids: string[],
): Promise<Map<string, { userId: string; publicKey: Uint8Array }>> {
if (ids.length === 0) return new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('devices').select('id, user_id, public_key').in('id', ids);
if (error) throw error;
const out = new Map<string, { userId: string; publicKey: Uint8Array }>();
for (const row of (data ?? []) as { id: string; user_id: string; public_key: string }[]) {
out.set(row.id, { userId: row.user_id, publicKey: pgHexToBytes(row.public_key) });
}
return out;
}
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
const result: MigrateResult = { migratedConversations: 0, errors: [] };
if (params.ownLegacyDeviceIds.length === 0) return result;
// PostgREST translates `.eq('col', null)` to `col=eq.null` which evaluates as
// `col = NULL` — always false in SQL. `.is('col', null)` produces `col IS NULL`,
// which is what we want here. Wrong filter silently returned zero rows so the
// entire migration was a no-op (0.18.0 bug; users could set a PIN but no
// conv-key bundles got re-wrapped → "Awaiting key" on every send).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: rowsRaw, error } = await (params.client as any)
.from('conversation_keys')
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
.in('recipient_device_id', params.ownLegacyDeviceIds)
.is('recipient_user_id', null);
if (error) throw error;
const rows = (rowsRaw ?? []) as LegacyRow[];
if (rows.length === 0) return result;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds);
for (const row of rows) {
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
if (!ownPriv) {
result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' });
continue;
}
const sender = senderMap.get(row.sender_device_id);
if (!sender) {
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
continue;
}
let convKey: Uint8Array;
try {
convKey = await decryptFrom(
pgHexToBytes(row.encrypted_key), pgHexToBytes(row.nonce),
sender.publicKey, ownPriv,
);
} catch (err) {
result.errors.push({
conversationId: row.conversation_id,
reason: err instanceof Error ? err.message : String(err),
});
continue;
}
const wrapped = await encryptFor(convKey, params.ownNewPublicKey, params.ownNewPrivateKey);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: rpcError } = await (params.client as any).rpc('migrate_user_key_recipients', {
p_conv_id: row.conversation_id,
p_user_id: params.ownUserId,
p_key_version: row.key_version,
p_bundles: [{
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
sender_user_id: row.sender_user_id ?? params.ownUserId,
}],
});
if (rpcError) {
result.errors.push({
conversationId: row.conversation_id,
reason: (rpcError as Error).message ?? String(rpcError),
});
continue;
}
result.migratedConversations += 1;
}
return result;
}