Compare commits

...

12 Commits

Author SHA1 Message Date
byGalax c55173800b chore(desktop): release v0.18.6 2026-05-16 01:13:54 +02:00
byGalax fc9f1ec143 fix(desktop): Settings sidebar redirected to /chats instead of scrolling
App uses HashRouter, so an <a href="#profile"> changes the routing
hash and the router can't find a match — it falls back to /chats.
Replace anchors with buttons that scroll the target section via
scrollIntoView and update the active highlight optimistically.
2026-05-16 01:12:50 +02:00
byGalax 12e4b597a0 chore(desktop): release v0.18.5 2026-05-16 01:09:54 +02:00
byGalax 5c05afb009 feat(desktop): redesign Settings + fix unreadable bubble text
Two UI fixes:

1. MessageBubble: 'Nachricht nicht lesbar' was rendered with
   text-fg-muted on the blue 'mine' bubble — invisible. Now uses
   text-accent-fg/80 on mine, text-fg-muted on peer (still
   ≥4.5:1 contrast in both modes).

2. SettingsPage: redesigned from a long single-column scroll into a
   sticky-sidebar + content layout (lg+) with:
   - 9 anchor-linked sections with icons in the sidebar
   - IntersectionObserver highlights the active section
   - Each section has a description subtitle for context
   - Voice (the densest section) is now sub-grouped into Audio-Gerät /
     Qualität / PTT / Hotkeys / E2EE via SubSection cards
   - Notifications consolidates message-sound + ringtone
   - Danger-toned account section visually separated
   - Mobile fallback is the original single-column scroll
2026-05-16 01:08:28 +02:00
byGalax a636a3c1c1 chore(desktop): release v0.18.4 2026-05-16 01:00:34 +02:00
byGalax c9fe4879e0 fix(shared): stop sending fake install-id as messages.sender_device_id
Task 12 (the AuthContext userKeyState refactor) replaced the per-device
DeviceRecord lookup with a localStorage UUID via ensureInstallId(). That
UUID was then passed straight through to messages.sender_device_id on
INSERT.

The messages_insert_member RLS policy requires sender_device_id to be
NULL OR to match a row in `devices` owned by the caller. The localStorage
UUID matches neither -> 403 -> outbox endlessly retries with "Wiederhole".

Fix: SendMessageParams.senderDeviceId becomes optional, and the message
INSERT coerces undefined to NULL. The column is pure telemetry post-conv-
keys so passing NULL is correct. Existing call sites that hand in
ensureInstallId() still typecheck (string is assignable to string|null|undefined)
but the row is written with NULL until those callers stop passing it.
2026-05-16 00:59:28 +02:00
byGalax 61462516d2 chore(desktop): release v0.18.3 2026-05-16 00:49:32 +02:00
byGalax d39a0fb6dc fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs
Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben":
uploadUserKeyBlob (called by setupNewUserIdentity, changePin and
regenerateRecoveryCode) routed through reset_user_key, which DELETES
every conversation_keys row addressed to the user or one of their
devices. So setting a PIN destroyed every legacy bundle BEFORE the
migration could re-wrap them. The user ended up with user_keys set,
zero un-migrated bundles, no decryption, no send.

Fixes shipped:

  * supabase/migrations/20260516000001_user_key_rpcs_v2.sql
    - upsert_user_key: same UPSERT, NO delete. Used everywhere except
      "Identität zurücksetzen" (which keeps reset_user_key on purpose).
    - rotate_conv_key: bumps active_key_version atomically and inserts
      a fresh batch of bundles (per-user + per-device fallback).
  * shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key.
  * shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh
    conv-key for every member's user_keys (preferred) and falls back to
    each member's per-device public_key for peers still on 0.17.x.
  * shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate
    when the user has no recipient_user_id row at the active version
    but rows exist (the deadlock case). Existing outbox retries drain
    on their own once the rotate completes — no manual button.
  * desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer,
    German "Nachricht nicht lesbar" so users don't think the app
    crashed when historical messages can't be unwrapped.
2026-05-16 00:48:28 +02:00
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
14 changed files with 857 additions and 164 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.18.0",
"version": "0.18.6",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+12 -1
View File
@@ -416,7 +416,18 @@ export function MessageBubble({
</button>
)}
{message.plaintext === null ? (
<span className="italic opacity-70">cannot decrypt</span>
<span
className={
'italic ' +
// Mine = blue/accent bubble → use accent-fg with reduced opacity
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
}
>
{t('app:chats.unreadable', {
defaultValue: 'Nachricht nicht lesbar',
})}
</span>
) : parsed.kind === 'poll' ? (
<PollCard
question={parsed.question}
+24 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
interface Props {
value: string;
@@ -12,7 +12,12 @@ interface Props {
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
const ref = useRef<HTMLInputElement | null>(null);
const [focused, setFocused] = useState(false);
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 (
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
<input
@@ -26,20 +31,30 @@ export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ari
value={value}
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
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"
/>
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
const active = focused && i === activeIndex;
let classes =
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
if (filled) {
classes += 'border-brand-400 bg-brand-500/10 text-white';
} else if (active) {
// 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';
}
return (
<span key={i}
className={
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' +
(filled
? 'border-brand-400 bg-brand-500/10 text-white'
: 'border-white/10 bg-ink-800 text-neutral-500')
}
>{filled ? '•' : ''}</span>
<span key={i} className={classes}>
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
</span>
);
})}
</div>
+51 -1
View File
@@ -1,6 +1,12 @@
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 { ShieldIcon, SpinnerIcon } from './icons';
@@ -12,6 +18,17 @@ export function SecurityCenter({ userId }: Props) {
const [busy, setBusy] = useState(false);
const [msg, setMsg] = 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() {
setBusy(true); setMsg(null);
@@ -76,6 +93,39 @@ export function SecurityCenter({ userId }: Props) {
)}
</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>
<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>
+7 -1
View File
@@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next';
import { ensureInstallId } from '../lib/installId';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
// 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);
if (cached) {
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;
}
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,
});
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);
});
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 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 devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' };
}
@@ -133,21 +148,84 @@ export async function resetIdentity(params: { userId: string; pin: string }): Pr
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(
userId: string,
ownNewPriv: 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);
if (devices.length === 0) return;
report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
// 1) Try every server-listed device first.
for (const d of devices) {
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
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);
if (ids.length === 0) return;
await migrateOwnLegacyBundles({
if (ids.length === 0) {
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase,
ownUserId: userId,
ownNewPublicKey: ownNewPub,
@@ -155,6 +233,22 @@ async function runLegacyMigration(
ownLegacyDeviceIds: ids,
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> {
+326 -131
View File
@@ -8,6 +8,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import {
AtIcon,
BellIcon,
LockIcon,
MicIcon,
MonitorShareIcon,
MusicIcon,
ShieldIcon,
SignOutIcon,
SunIcon,
UsersIcon,
} from '../components/icons';
import { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings';
@@ -99,140 +111,263 @@ export function SettingsPage() {
void patchProfile({ locale });
}
// Single-page layout with a sticky sidebar of anchor links on wide screens.
// Each Section gets a stable `id` so links scroll to it; an IntersectionObserver
// highlights whichever section is currently in view (active link).
const sections = [
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
];
const [activeId, setActiveId] = useState<string>(sections[0]!.id);
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible?.target.id) setActiveId(visible.target.id);
},
{ rootMargin: '-20% 0px -70% 0px', threshold: [0, 0.5, 1] },
);
sections.forEach((s) => {
const el = document.getElementById(s.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
</header>
{/* Account */}
<Section title={t('app:settings.section_account')}>
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Startup */}
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
<AutoStartControls />
</Section>
{/* Appearance */}
<Section title={t('app:settings.section_appearance')}>
<ThemeRow />
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
{/* Sidebar — sticky anchor nav, hidden on narrow screens */}
<aside className="hidden lg:block">
<div className="sticky top-8 space-y-1">
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<nav aria-label={t('app:settings.title')}>
{sections.map(({ id, label, Icon }) => {
const active = activeId === id;
return (
// NOTE: must NOT be an `<a href="#id">` — the app uses
// HashRouter where the URL hash IS the route. Anchor links
// would navigate away from /settings instead of scrolling.
// We scroll the section into view programmatically and
// optimistically set activeId so the click feels instant.
<button
key={id}
type="button"
onClick={() => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveId(id);
}
}}
className={
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent/15 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
<Icon
className={
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
}
/>
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
</div>
</SettingRow>
</Section>
</aside>
{/* Privacy */}
<Section title={t('app:settings.section_privacy')}>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Content column */}
<main className="space-y-8">
{/* Mobile-only header (sidebar replaces it on lg+) */}
<header className="lg:hidden">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
</header>
{/* Notification sound (new messages) */}
<Section
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
>
<NotificationSoundSettings disabled={busy} />
</Section>
<Section
id="profile"
title={t('app:settings.section_account')}
description={t('app:settings.section_account_hint', {
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
})}
>
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
<Section
id="appearance"
title={t('app:settings.section_appearance')}
description={t('app:settings.section_appearance_hint', {
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
})}
>
<ThemeRow />
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
</div>
</SettingRow>
<SubGroup>
<AutoStartControls />
</SubGroup>
</Section>
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</Section>
<Section
id="privacy"
title={t('app:settings.section_privacy')}
description={t('app:settings.section_privacy_hint', {
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
})}
>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls />
<div className="mt-3 border-t border-line pt-3">
<AudioQualityControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<PttControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="mute" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="deafen" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="hangup" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="screenShare" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="video" />
</div>
<div className="mt-3 border-t border-line pt-3">
<CallE2EEControls />
</div>
</Section>
<Section
id="notifications"
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
description={t('app:settings.section_notifications_hint', {
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
<NotificationSoundSettings disabled={busy} />
</SubSection>
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
<RingtoneSettings disabled={busy} />
</SubSection>
</Section>
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
<Section
id="voice"
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
description={t('app:settings.section_voice_hint', {
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
<AudioDeviceControls />
</SubSection>
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
<AudioQualityControls />
</SubSection>
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
<PttControls />
</SubSection>
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
<div className="space-y-2">
<VoiceHotkeyControls kind="mute" />
<VoiceHotkeyControls kind="deafen" />
<VoiceHotkeyControls kind="hangup" />
<VoiceHotkeyControls kind="screenShare" />
<VoiceHotkeyControls kind="video" />
</div>
</SubSection>
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
<CallE2EEControls />
</SubSection>
</Section>
{/* Security */}
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
<Section
id="screen-share"
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
description={t('app:settings.section_screen_share_hint', {
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
})}
>
<ScreenShareControls />
</Section>
{/* Danger zone */}
<Section title={t('app:settings.danger_zone')}>
<button
type="button"
onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
>
{t('app:settings.sign_out')}
</button>
</Section>
<Section
id="soundboard"
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
description={t('app:settings.section_soundboard_hint', {
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
})}
>
<SoundboardSettings />
</Section>
<Section
id="security"
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
description={t('app:settings.section_security_hint', {
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
})}
>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
<Section
id="account"
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.',
})}
tone="danger"
>
<button
type="button"
onClick={() => void signOut()}
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
>
<SignOutIcon className="h-4 w-4" />
{t('app:settings.sign_out')}
</button>
</Section>
</main>
</div>
</div>
);
@@ -1138,21 +1273,81 @@ function formatBitrate(kbps: number): string {
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
id,
title,
description,
children,
tone,
}: {
id: string;
title: string;
description?: string;
children: React.ReactNode;
tone?: 'default' | 'danger';
}) {
return (
<section className="rounded-2xl border border-line bg-surface-2 p-5">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h2>
<div className="space-y-3">{children}</div>
<section
id={id}
// scroll-mt offsets the anchor target so the section heading isn't flush
// against the viewport top after a sidebar-link jump
className={
'scroll-mt-8 rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
}
>
<header className="mb-5 border-b border-line pb-4">
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
{title}
</h2>
{description && (
<p className="mt-1 text-xs text-fg-muted">{description}</p>
)}
</header>
<div className="space-y-4">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
// Sub-heading inside a Section — used to chunk dense sections like Voice into
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2 rounded-xl border border-line/60 bg-surface-3/40 p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h3>
<div className="space-y-3">{children}</div>
</div>
);
}
// Lighter wrapper for a single related extra control inside a Section that
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
// Appearance).
function SubGroup({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-3 border-t border-line pt-4">{children}</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string;
mono?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{label}</dt>
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
{icon}
{label}
</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-fg ' +
+5 -3
View File
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
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<string, unknown>;
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.00.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=');
+7 -1
View File
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise<void> {
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.00.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),
+95 -1
View File
@@ -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<ConvKeyHandle> {
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,
+12 -3
View File
@@ -91,7 +91,11 @@ export interface SendMessageParams {
conversationId: string;
plaintext: string;
senderUserId: string;
senderDeviceId: string;
// Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
// started passing a localStorage UUID that doesn't exist in the devices
// table; messages.sender_device_id RLS then 403'd every insert. Senders
// pass null (or an actually-registered device id, if they have one).
senderDeviceId?: string | null;
senderPrivateKey: Uint8Array;
replyToId?: string;
// Optional encrypted attachments — their handles are already materialised
@@ -124,7 +128,12 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
const insertPayload: Record<string, unknown> = {
conversation_id: params.conversationId,
sender_id: params.senderUserId,
sender_device_id: params.senderDeviceId,
// ALWAYS null until we re-introduce a real per-install devices row.
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
// which doesn't exist in the devices table; the messages_insert_member
// RLS policy then 403s because the id can't be proven to belong to the
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
sender_device_id: null,
ciphertext: bytesToPgHex(cipher.ciphertext),
nonce: bytesToPgHex(cipher.nonce),
key_version: handle.keyVersion,
@@ -174,7 +183,7 @@ export interface EditMessageParams {
// Re-encrypts the message body with the conv-key and updates the row.
// Server-side trigger enforces 24h window + sender-only rule.
export async function editEncryptedMessage(
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
params: EditMessageParams & { senderUserId: string; senderDeviceId?: string | null },
): Promise<void> {
const ownCtx: OwnUserCtx = {
userId: params.senderUserId,
@@ -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: () => ({
eq: () => Promise.resolve({
is: () => ({
not: () => Promise.resolve({
data: [
{
conversation_id: 'conv-1',
+42 -5
View File
@@ -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,30 +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;
}
// 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)
.eq('recipient_user_id', null as unknown as string);
.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;
}
@@ -79,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),
@@ -98,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),
@@ -107,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;
}
@@ -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;