Compare commits

...

4 Commits

Author SHA1 Message Date
byGalax 9207f473cd chore(desktop): release v0.18.2 2026-05-16 00:27:54 +02:00
byGalax 5367544b59 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.
2026-05-16 00:25:48 +02:00
byGalax e6b698bf14 chore(desktop): release v0.18.1 2026-05-16 00:16:53 +02:00
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
7 changed files with 227 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.18.0", "version": "0.18.2",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+24 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
interface Props { interface Props {
value: string; value: string;
@@ -12,7 +12,12 @@ interface Props {
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) { export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const [focused, setFocused] = useState(false);
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]); useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
// Index of the next empty slot the next keystroke will fill. When the user
// has typed all `length` digits, no slot is "active" — the form should
// submit instead of pretending one is still focused.
const activeIndex = value.length < length ? value.length : -1;
return ( return (
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}> <div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
<input <input
@@ -26,20 +31,30 @@ export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ari
value={value} value={value}
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))} onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }} onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute h-px w-px overflow-hidden p-0 opacity-0" className="absolute h-px w-px overflow-hidden p-0 opacity-0"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
{Array.from({ length }).map((_, i) => { {Array.from({ length }).map((_, i) => {
const filled = i < value.length; const filled = i < value.length;
return ( const active = focused && i === activeIndex;
<span key={i} let classes =
className={ 'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' + if (filled) {
(filled classes += 'border-brand-400 bg-brand-500/10 text-white';
? 'border-brand-400 bg-brand-500/10 text-white' } else if (active) {
: 'border-white/10 bg-ink-800 text-neutral-500') // Brand-coloured ring + glow so the user immediately sees where
// the next keystroke lands. The animated cursor inside reinforces
// the "input is alive" feeling.
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
} else {
classes += 'border-white/10 bg-ink-800 text-neutral-500';
} }
>{filled ? '•' : ''}</span> return (
<span key={i} className={classes}>
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
</span>
); );
})} })}
</div> </div>
+51 -1
View File
@@ -1,6 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity'; import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput'; import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons'; import { ShieldIcon, SpinnerIcon } from './icons';
@@ -12,6 +18,17 @@ export function SecurityCenter({ userId }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(null); const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
try {
const report = await retryLegacyMigration(userId);
setMigration(report);
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleChangePin() { async function handleChangePin() {
setBusy(true); setMsg(null); setBusy(true); setMsg(null);
@@ -76,6 +93,39 @@ export function SecurityCenter({ userId }: Props) {
)} )}
</section> </section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
<p className="mb-2 text-xs text-fg-muted">
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
</p>
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
</button>
{migration && (
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
<div>Geräte (Server): {migration.serverDevices}</div>
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
{migration.strongholdKeysFromBundleScan > 0 && (
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
)}
</div>
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
<div>Server-Fehler: {migration.rpcFailed}</div>
{migration.attempted > 0 && migration.migrated === 0 && (
<p className="mt-2 text-rose-300">
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
</p>
)}
</div>
)}
</section>
<section> <section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3> <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p> <p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
+7 -1
View File
@@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
import { setSecretStoreUser } from '../lib/secretStore'; import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity'; import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush'; import { registerWebPush } from '../lib/webPush';
// Discriminated union describing the per-user encrypted key blob lifecycle: // Discriminated union describing the per-user encrypted key blob lifecycle:
@@ -137,6 +137,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const cached = await cachedUserKey(session.user.id); const cached = await cachedUserKey(session.user.id);
if (cached) { if (cached) {
setUserKeyState({ status: 'unlocked' }); setUserKeyState({ status: 'unlocked' });
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
// where the migration query had a `.eq(null)` bug that made it a no-op.
void ensureLegacyMigrated(session.user.id).catch((err) => {
console.warn('legacy conv-key migration on auth-resume failed', err);
});
return; return;
} }
const blob = await fetchUserKeyBlob(supabase, session.user.id); const blob = await fetchUserKeyBlob(supabase, session.user.id);
+99 -5
View File
@@ -36,12 +36,24 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
recoverySalt: recoverySealed?.salt ?? null, recoverySalt: recoverySealed?.salt ?? null,
}); });
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey); await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
void runLegacyMigration(p.userId, kp.privateKey, kp.publicKey).catch((err) => { void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err); console.warn('legacy conv-key migration failed', err);
}); });
return { publicKey: kp.publicKey, recoveryCode }; return { publicKey: kp.publicKey, recoveryCode };
} }
// Background, idempotent re-wrap of own legacy conv-key bundles for the new
// per-user identity. Safe to call repeatedly: the underlying RPC uses
// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who
// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a
// no-op) auto-recover on the next launch.
export async function ensureLegacyMigrated(userId: string): Promise<void> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) return;
const pub = await derivePublicKey(priv);
await runLegacyMigration(userId, priv, pub);
}
export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean } export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
export type UnlockOutcome = export type UnlockOutcome =
@@ -66,6 +78,9 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
} }
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {}); await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv); await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' }; return { kind: 'unlocked' };
} }
@@ -133,21 +148,84 @@ export async function resetIdentity(params: { userId: string; pin: string }): Pr
return setup.recoveryCode ?? ''; return setup.recoveryCode ?? '';
} }
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
export interface LegacyMigrationReport {
serverDevices: number;
strongholdKeysFromServerDevices: number;
strongholdKeysFromBundleScan: number;
attempted: number;
migrated: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
async function runLegacyMigration( async function runLegacyMigration(
userId: string, userId: string,
ownNewPriv: Uint8Array, ownNewPriv: Uint8Array,
ownNewPub: Uint8Array, ownNewPub: Uint8Array,
): Promise<void> { ): Promise<LegacyMigrationReport> {
const report: LegacyMigrationReport = {
serverDevices: 0,
strongholdKeysFromServerDevices: 0,
strongholdKeysFromBundleScan: 0,
attempted: 0,
migrated: 0,
noStrongholdKey: 0,
decryptFailed: 0,
rpcFailed: 0,
};
const devices = await listOwnDevices(supabase); const devices = await listOwnDevices(supabase);
if (devices.length === 0) return; report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {}; const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
// 1) Try every server-listed device first.
for (const d of devices) { for (const d of devices) {
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`); const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
if (k) ownLegacyDevicePrivateKeys[d.id] = k; if (k) ownLegacyDevicePrivateKeys[d.id] = k;
} }
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
// 2) Scan our visible un-migrated conversation_keys for distinct
// recipient_device_ids and probe stronghold for each. This catches the case
// where a device row was deleted server-side but its key remains locally,
// OR where listOwnDevices missed a device because of an RLS edge.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: scanRowsRaw } = await (supabase as any)
.from('conversation_keys')
.select('recipient_device_id')
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
const scanIds = Array.from(new Set(
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
.map((r) => r.recipient_device_id)
.filter((id): id is string => !!id),
));
for (const id of scanIds) {
if (ownLegacyDevicePrivateKeys[id]) continue;
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
if (k) {
ownLegacyDevicePrivateKeys[id] = k;
report.strongholdKeysFromBundleScan += 1;
}
}
console.info(
'[crypto-migration] vault scan:',
'serverDevices=' + report.serverDevices,
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
);
const ids = Object.keys(ownLegacyDevicePrivateKeys); const ids = Object.keys(ownLegacyDevicePrivateKeys);
if (ids.length === 0) return; if (ids.length === 0) {
await migrateOwnLegacyBundles({ console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase, client: supabase,
ownUserId: userId, ownUserId: userId,
ownNewPublicKey: ownNewPub, ownNewPublicKey: ownNewPub,
@@ -155,6 +233,22 @@ async function runLegacyMigration(
ownLegacyDeviceIds: ids, ownLegacyDeviceIds: ids,
ownLegacyDevicePrivateKeys, ownLegacyDevicePrivateKeys,
}); });
report.attempted = result.attempted;
report.migrated = result.migratedConversations;
report.noStrongholdKey = result.noStrongholdKey;
report.decryptFailed = result.decryptFailed;
report.rpcFailed = result.rpcFailed;
return report;
}
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
// Returns a structured report so the UI can render numbers and reasons.
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = await derivePublicKey(priv);
return runLegacyMigration(userId, priv, pub);
} }
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> { async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
@@ -23,10 +23,11 @@ describe('migrateOwnLegacyBundles', () => {
const stubClient = { const stubClient = {
from: (table: string) => { from: (table: string) => {
if (table === 'conversation_keys') { if (table === 'conversation_keys') {
// Mirrors the new chain: .select(...).is('recipient_user_id', null).not('recipient_device_id', 'is', null)
return { return {
select: () => ({ select: () => ({
in: () => ({ is: () => ({
eq: () => Promise.resolve({ not: () => Promise.resolve({
data: [ data: [
{ {
conversation_id: 'conv-1', conversation_id: 'conv-1',
+42 -5
View File
@@ -14,6 +14,13 @@ export interface MigrateParams {
export interface MigrateResult { export interface MigrateResult {
migratedConversations: number; migratedConversations: number;
errors: { conversationId: string; reason: string }[]; 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 { interface LegacyRow {
@@ -45,30 +52,50 @@ async function fetchSenderPubKeys(
} }
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> { export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
const result: MigrateResult = { migratedConversations: 0, errors: [] }; const result: MigrateResult = {
if (params.ownLegacyDeviceIds.length === 0) return result; 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;
}
// 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: rowsRaw, error } = await (params.client as any) const { data: rowsRaw, error } = await (params.client as any)
.from('conversation_keys') .from('conversation_keys')
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce') .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)
.eq('recipient_user_id', null as unknown as string); .not('recipient_device_id', 'is', null);
if (error) throw error; if (error) throw error;
const rows = (rowsRaw ?? []) as LegacyRow[]; const rows = (rowsRaw ?? []) as LegacyRow[];
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
if (rows.length === 0) return result; if (rows.length === 0) return result;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds); const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds);
for (const row of rows) { for (const row of rows) {
result.attempted += 1;
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id]; const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
if (!ownPriv) { 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; continue;
} }
const sender = senderMap.get(row.sender_device_id); const sender = senderMap.get(row.sender_device_id);
if (!sender) { if (!sender) {
result.decryptFailed += 1;
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' }); result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
continue; continue;
} }
@@ -79,6 +106,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
sender.publicKey, ownPriv, sender.publicKey, ownPriv,
); );
} catch (err) { } catch (err) {
result.decryptFailed += 1;
result.errors.push({ result.errors.push({
conversationId: row.conversation_id, conversationId: row.conversation_id,
reason: err instanceof Error ? err.message : String(err), reason: err instanceof Error ? err.message : String(err),
@@ -98,6 +126,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
}], }],
}); });
if (rpcError) { if (rpcError) {
result.rpcFailed += 1;
result.errors.push({ result.errors.push({
conversationId: row.conversation_id, conversationId: row.conversation_id,
reason: (rpcError as Error).message ?? String(rpcError), reason: (rpcError as Error).message ?? String(rpcError),
@@ -107,5 +136,13 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
result.migratedConversations += 1; 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; return result;
} }