6caa674c19
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.
117 lines
4.4 KiB
TypeScript
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;
|
|
}
|