feat(desktop): diagnostic + manual retry for legacy key migration
The 0.18.1 fix relied on an existing-device + present-stronghold-key match.
That fails for users who:
- had multiple device registrations and only retain the latest device's
private key in the local vault
- had a vault wipe / fresh OS install at some point
- have device rows that vanished server-side but keys still locally
Migration now scans conversation_keys for distinct un-migrated
recipient_device_ids visible to the user (RLS-filtered) and probes the
stronghold for each, regardless of whether the server still lists that
device. Result struct surfaces attempted/migrated/noKey/decryptFail/rpcFail
counters; SecurityCenter shows them via a new "Migration erneut ausführen"
button so users can self-diagnose without DevTools.
Also adds [crypto-migration] console.info breadcrumbs at every decision
point so a single F12 shows what happened.
This commit is contained in:
@@ -23,10 +23,11 @@ describe('migrateOwnLegacyBundles', () => {
|
||||
const stubClient = {
|
||||
from: (table: string) => {
|
||||
if (table === 'conversation_keys') {
|
||||
// Mirrors the new chain: .select(...).is('recipient_user_id', null).not('recipient_device_id', 'is', null)
|
||||
return {
|
||||
select: () => ({
|
||||
in: () => ({
|
||||
is: () => Promise.resolve({
|
||||
is: () => ({
|
||||
not: () => Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
conversation_id: 'conv-1',
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface MigrateParams {
|
||||
export interface MigrateResult {
|
||||
migratedConversations: number;
|
||||
errors: { conversationId: string; reason: string }[];
|
||||
// Per-pair detail collected even on success so the SecurityCenter "retry"
|
||||
// button can show "X / Y bundles re-wrapped, Z stuck because the device
|
||||
// private key is no longer in the local vault."
|
||||
attempted: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
interface LegacyRow {
|
||||
@@ -45,35 +52,50 @@ async function fetchSenderPubKeys(
|
||||
}
|
||||
|
||||
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
|
||||
const result: MigrateResult = { migratedConversations: 0, errors: [] };
|
||||
if (params.ownLegacyDeviceIds.length === 0) return result;
|
||||
const result: MigrateResult = {
|
||||
migratedConversations: 0, errors: [],
|
||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||
};
|
||||
if (params.ownLegacyDeviceIds.length === 0) {
|
||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
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).
|
||||
// RLS already filters this query down to rows where one of the recipient
|
||||
// device-ids belongs to us. We do NOT pre-filter by `ownLegacyDeviceIds`
|
||||
// anymore — historically, users can have device rows server-side whose
|
||||
// private key is no longer in the local vault (fresh OS install, vault
|
||||
// wiped, etc.). Conversely, the vault may hold a key for a device-id the
|
||||
// server forgot. The decisive check is "do we have the matching private
|
||||
// key in stronghold?" — which we evaluate per row below.
|
||||
// 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);
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
if (error) throw error;
|
||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
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) {
|
||||
result.attempted += 1;
|
||||
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
|
||||
if (!ownPriv) {
|
||||
result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' });
|
||||
result.noStrongholdKey += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: 'no private key in stronghold for device ' + row.recipient_device_id.slice(0, 8),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sender = senderMap.get(row.sender_device_id);
|
||||
if (!sender) {
|
||||
result.decryptFailed += 1;
|
||||
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
|
||||
continue;
|
||||
}
|
||||
@@ -84,6 +106,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
sender.publicKey, ownPriv,
|
||||
);
|
||||
} catch (err) {
|
||||
result.decryptFailed += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
@@ -103,6 +126,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
}],
|
||||
});
|
||||
if (rpcError) {
|
||||
result.rpcFailed += 1;
|
||||
result.errors.push({
|
||||
conversationId: row.conversation_id,
|
||||
reason: (rpcError as Error).message ?? String(rpcError),
|
||||
@@ -112,5 +136,13 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
result.migratedConversations += 1;
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user