diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 88b0d2a..4eb9b3c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -37,6 +37,16 @@ const __dirnameSafe = path.dirname(__filenameSafe); const DEV_URL = 'http://localhost:1420'; const WINDOW_STATE_FILE = 'window-state.json'; +// Run dev side-by-side with the installed packaged build by isolating the +// renderer profile / secret-store / SQLite / IndexedDB / localStorage in +// a separate userData dir. Without this both share `%APPDATA%\ChatApp`, +// the single-instance lock fires, and `pnpm dev` exits immediately while +// the installed prod app holds the lock. Must run BEFORE the lock check +// below + before any other module reads `app.getPath('userData')`. +if (!app.isPackaged) { + app.setPath('userData', app.getPath('userData') + '-Dev'); +} + let mainWindow: BrowserWindow | null = null; function resolvePreloadPath(): string { @@ -64,7 +74,7 @@ async function createWindow(): Promise { const state = await loadState(WINDOW_STATE_FILE); const win = new BrowserWindow({ - title: 'ChatApp', + title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)', width: state.width, height: state.height, ...(state.x !== undefined ? { x: state.x } : {}), diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index 8bb9c7f..06b0d9c 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -1,11 +1,15 @@ +import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { startConversationKeySync } from '../lib/conversationKeySync'; +import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { ensureNotificationPermission } from '../lib/osNotify'; +import { devLocalSecretStore } from '../lib/secretStore'; import { BackupPromptBanner } from './BackupPromptBanner'; import { CallUI } from './CallUI'; +import { DeviceApprovalBanner } from './DeviceApprovalBanner'; import { Sidebar } from './Sidebar'; export function AppShell() { @@ -18,7 +22,18 @@ export function AppShell() { useEffect(() => { if (!session?.user.id || !device?.id) return; - return startConversationKeySync(session.user.id, device.id); + const userId = session.user.id; + const deviceId = device.id; + const stopKeySync = startConversationKeySync(userId, deviceId); + const stopApproval = startDeviceApprovalListener({ + ownUserId: userId, + ownDeviceId: deviceId, + getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId), + }); + return () => { + stopKeySync(); + stopApproval(); + }; }, [session?.user.id, device?.id]); return ( @@ -34,6 +49,7 @@ export function AppShell() { + ); } diff --git a/apps/desktop/src/components/DeviceApprovalBanner.tsx b/apps/desktop/src/components/DeviceApprovalBanner.tsx new file mode 100644 index 0000000..ec340d3 --- /dev/null +++ b/apps/desktop/src/components/DeviceApprovalBanner.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + approveDevice, + denyDevice, + type PendingApproval, + subscribePendingApprovals, +} from '../lib/deviceApproval'; +import { LockIcon, SpinnerIcon, XIcon } from './icons'; + +// Sticky bottom-right banner stack (Discord-style). One tile per pending +// device-approval request. Visual language deliberately mirrors +// `BackupPromptBanner` — fixed positioning, rounded panel, subtle border +// accent — but uses the brand/accent palette to distinguish "security +// decision" from the amber "you should make a backup" nudge. +// +// Data flow: +// 1. `startDeviceApprovalListener` (mounted from AppShell) seeds the +// pending list on connect + on realtime INSERTs. +// 2. This component subscribes to that module and re-renders. +// 3. On Genehmigen: calls `approveDevice` which re-uses the +// `wrapForOneDevice` helper from conversationKeySync to write conv-key +// bundles for the new device across every shared conversation. +// 4. On Ablehnen: persists the deviceId in localStorage so it doesn't +// re-surface on app reload. +export function DeviceApprovalBanner() { + const { t, i18n } = useTranslation(['app']); + const [pending, setPending] = useState([]); + const [busyId, setBusyId] = useState(null); + const [errorId, setErrorId] = useState(null); + + useEffect(() => subscribePendingApprovals(setPending), []); + + const onApprove = useCallback(async (req: PendingApproval) => { + setErrorId(null); + setBusyId(req.deviceId); + try { + await approveDevice(req); + } catch (err) { + console.warn('deviceApproval: approve failed', err); + setErrorId(req.deviceId); + } finally { + setBusyId((curr) => (curr === req.deviceId ? null : curr)); + } + }, []); + + const onDeny = useCallback((deviceId: string) => { + denyDevice(deviceId); + }, []); + + if (pending.length === 0) return null; + + return ( +
+ {pending.map((req) => { + const busy = busyId === req.deviceId; + const errored = errorId === req.deviceId; + return ( +
+ +
+

+ {t('app:device_approval.title', { + defaultValue: 'Neues Gerät registriert', + })} +

+

+ {formatDeviceLabel(req)} ·{' '} + {formatRelativeTime(req.createdAt, i18n.language)} +

+

+ {t('app:device_approval.question', { + defaultValue: 'War das du?', + })} +

+ {errored && ( +

+ {t('app:device_approval.error', { + defaultValue: + 'Genehmigung fehlgeschlagen. Versuch es nochmal.', + })} +

+ )} +
+ + +
+
+ +
+ ); + })} +
+ ); +} + +function formatDeviceLabel(req: PendingApproval): string { + const platform = humanPlatform(req.platform); + const name = (req.name ?? '').trim(); + if (name && platform) return `${platform} · ${name}`; + if (name) return name; + if (platform) return platform; + return 'Unbekanntes Gerät'; +} + +function humanPlatform(p: string): string { + switch (p) { + case 'windows': + return 'Windows'; + case 'macos': + return 'macOS'; + case 'linux': + return 'Linux'; + case 'ios': + return 'iOS'; + case 'android': + return 'Android'; + default: + return p.length > 0 ? p.charAt(0).toUpperCase() + p.slice(1) : ''; + } +} + +// Best-effort relative-time formatter using Intl.RelativeTimeFormat. +// Falls back to absolute timestamp if anything goes sideways. +function formatRelativeTime(iso: string, locale: string): string { + try { + const ts = Date.parse(iso); + if (Number.isNaN(ts)) return iso; + const diffSec = Math.round((ts - Date.now()) / 1000); + const abs = Math.abs(diffSec); + const rtf = new Intl.RelativeTimeFormat(locale || 'de', { numeric: 'auto' }); + if (abs < 60) return rtf.format(diffSec, 'second'); + if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute'); + if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour'); + return rtf.format(Math.round(diffSec / 86400), 'day'); + } catch { + return iso; + } +} diff --git a/apps/desktop/src/lib/conversationKeySync.ts b/apps/desktop/src/lib/conversationKeySync.ts index a285117..50d05e8 100644 --- a/apps/desktop/src/lib/conversationKeySync.ts +++ b/apps/desktop/src/lib/conversationKeySync.ts @@ -12,7 +12,7 @@ import { supabase } from './supabase'; // This fixes the "cannot decrypt" cliff for devices that registered while // no other participant device was online to share the key with them. -interface SyncCtx { +export interface SyncCtx { myUserId: string; myDeviceId: string; priv: Uint8Array; @@ -32,46 +32,55 @@ export function startConversationKeySync( ownUserId: string, ownDeviceId: string, ): () => void { - let cancelled = false; - let priv: Uint8Array | null = null; - const dedupeKey = ownUserId + ':' + ownDeviceId; - - void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => { - if (cancelled) return; - priv = pk; - if (!priv) return; - if (backfilledKey.has(dedupeKey)) return; - backfilledKey.add(dedupeKey); - await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv }); - }); - - const channel = supabase - .channel('device-key-sync:' + ownDeviceId) - .on( - 'postgres_changes', - { event: 'INSERT', schema: 'public', table: 'devices' }, - (payload: { new: { id?: string; user_id?: string; public_key?: string } }) => { - if (cancelled) return; - const row = payload.new; - if (!row?.id || !row.user_id || !row.public_key) return; - if (row.user_id === ownUserId && row.id === ownDeviceId) return; - if (!priv) return; // backfill on mount will catch it later - void wrapForOneDevice( - { myUserId: ownUserId, myDeviceId: ownDeviceId, priv }, - row.id, - row.user_id, - row.public_key, - ); - }, - ) - .subscribe(); - - return () => { - cancelled = true; - void supabase.removeChannel(channel); - }; + // DISABLED: auto-share of conversation keys to newly-registered devices + // is gone. Without it, account takeover (stolen password / new device + // registered by attacker) no longer automatically grants history access + // — an attacker would have a working device-key but no conv-key wraps. + // + // History access paths still supported: + // 1. Backup-Restore — restores the OLD device-id + privkey, so the + // server-side wraps for that device-id are accessible as before. + // 2. (Planned) Approval flow — existing device or conversation peer + // explicitly approves a new device, then conv-keys are wrapped + // for it. Until that ships, fresh-login-without-backup means old + // conversations stay encrypted. + // + // For NEW conversations: the key is generated at conv-creation time + // and includes all current devices of all members, so a freshly-logged- + // in device CAN still participate in newly-created conversations. It + // just can't read the back-history of conversations it wasn't a member + // of when those messages were sealed. + // + // We deliberately keep the helper functions below (syncAllExistingGaps, + // wrapForOneDevice, …) intact so the upcoming approval flow can wire + // them to user-driven triggers without rebuilding from scratch. + void ownUserId; + void ownDeviceId; + void backfilledKey; + void loadDevicePrivateKey; + void devLocalSecretStore; + void supabase; + return () => {}; } +// Keep helpers alive across the auto-sync hibernation window so the +// upcoming approval flow can re-wire them. Without this no-op reference +// `tsc --noEmit` flags them as unused (TS6133). +// +// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for +// the device-approval module — once the user explicitly approves a new +// device the approval flow re-uses these helpers to wrap conv-keys for +// that specific deviceId. +void (() => { + void listMyConversationIds; + void listConversationDevices; + void listExistingKeyRecipients; + void getActiveKeyVersion; + void syncAllExistingGaps; + void isExpectedShareFailure; + void rawFrom; +}); + async function listMyConversationIds(myUserId: string): Promise { const { data, error } = await supabase .from('conversation_members') @@ -149,7 +158,7 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise { } } -async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise { +export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise { const version = await getActiveKeyVersion(convId); const devices = await listConversationDevices(convId); if (devices.length === 0) return; @@ -200,7 +209,7 @@ function isExpectedShareFailure(err: unknown): boolean { ); } -async function wrapForOneDevice( +export async function wrapForOneDevice( ctx: SyncCtx, newDeviceId: string, newDeviceUserId: string, diff --git a/apps/desktop/src/lib/deviceApproval.ts b/apps/desktop/src/lib/deviceApproval.ts new file mode 100644 index 0000000..edd7e1f --- /dev/null +++ b/apps/desktop/src/lib/deviceApproval.ts @@ -0,0 +1,276 @@ +import type { DevicePlatform } from '@chat-app/shared/supabase'; + +import { type SyncCtx, wrapForOneDevice } from './conversationKeySync'; +import { supabase } from './supabase'; + +// Device-approval flow (Phase 1). +// --------------------------------------------------------------------------- +// When the user registers a brand-new device on top of an existing one, the +// existing device must explicitly approve it before any conv-key wraps are +// created. This module: +// +// 1. On startup, fetches every device row owned by the user and surfaces +// the ones that aren't this device, aren't already approved, and aren't +// dismissed. Covers the "I was offline when the new device registered" +// case. +// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a +// device that registers WHILE this client is online raises a banner +// immediately. +// 3. Persists approve/deny decisions in localStorage so a reload doesn't +// ask again for a device the user already answered for. +// +// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that +// helper already walks every shared conversation and writes the key bundle +// for the target device. + +export interface PendingApproval { + deviceId: string; + userId: string; + name: string; + platform: DevicePlatform | string; + createdAt: string; + publicKey: string; // pg-hex-encoded bytea +} + +export interface DeviceApprovalListenerCtx { + ownUserId: string; + ownDeviceId: string; + // Lazy getter so we never hold the privkey in memory for longer than the + // approve action that needs it. Returns null if the key isn't loadable + // (e.g. fresh-restored device that hasn't unsealed yet). + getPriv: () => Promise; +} + +const APPROVED_KEY = 'chatapp.approvedDeviceIds'; +const DISMISSED_KEY = 'chatapp.dismissedDeviceIds'; + +// In-process state. Module-level so the banner component and the listener +// share one source of truth without prop-drilling through context. +let pending: PendingApproval[] = []; +const subscribers = new Set<(list: PendingApproval[]) => void>(); +let listenerCtx: DeviceApprovalListenerCtx | null = null; + +function readIdSet(key: string): Set { + try { + const raw = window.localStorage.getItem(key); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((v): v is string => typeof v === 'string')); + } catch { + return new Set(); + } +} + +function writeIdSet(key: string, set: Set): void { + try { + window.localStorage.setItem(key, JSON.stringify([...set])); + } catch { + /* storage unavailable — non-fatal */ + } +} + +function persistApproved(deviceId: string): void { + const s = readIdSet(APPROVED_KEY); + s.add(deviceId); + writeIdSet(APPROVED_KEY, s); +} + +function persistDismissed(deviceId: string): void { + const s = readIdSet(DISMISSED_KEY); + s.add(deviceId); + writeIdSet(DISMISSED_KEY, s); +} + +function notify(): void { + const snapshot = [...pending]; + for (const cb of subscribers) { + try { + cb(snapshot); + } catch (err) { + console.warn('deviceApproval: subscriber threw', err); + } + } +} + +export function getPendingApprovals(): PendingApproval[] { + return [...pending]; +} + +export function subscribePendingApprovals( + cb: (list: PendingApproval[]) => void, +): () => void { + subscribers.add(cb); + // Fire once with current state so the consumer can initialise without + // waiting for the next change. + try { + cb([...pending]); + } catch (err) { + console.warn('deviceApproval: initial subscriber call threw', err); + } + return () => { + subscribers.delete(cb); + }; +} + +function shouldSurface(deviceId: string, ownDeviceId: string): boolean { + if (deviceId === ownDeviceId) return false; + const approved = readIdSet(APPROVED_KEY); + if (approved.has(deviceId)) return false; + const dismissed = readIdSet(DISMISSED_KEY); + if (dismissed.has(deviceId)) return false; + return true; +} + +interface DeviceRowLite { + id: string; + user_id: string; + name: string; + platform: DevicePlatform | string; + created_at: string; + public_key: string; +} + +function rowToPending(row: DeviceRowLite): PendingApproval { + return { + deviceId: row.id, + userId: row.user_id, + name: row.name, + platform: row.platform, + createdAt: row.created_at, + publicKey: row.public_key, + }; +} + +function upsertPending(req: PendingApproval): void { + if (pending.some((p) => p.deviceId === req.deviceId)) return; + pending = [...pending, req]; + notify(); +} + +function removePending(deviceId: string): void { + const next = pending.filter((p) => p.deviceId !== deviceId); + if (next.length === pending.length) return; + pending = next; + notify(); +} + +async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise { + const { data, error } = await supabase + .from('devices') + .select('id, user_id, name, platform, created_at, public_key') + .eq('user_id', ctx.ownUserId); + if (error) { + console.warn('deviceApproval: initial devices lookup failed', error); + return; + } + const rows = (data ?? []) as DeviceRowLite[]; + + // Find OWN device's createdAt — anything older than that is a pre-existing + // device that was already legit before this client came online and should + // NOT raise an approval banner. Without this guard, a freshly-installed + // client lights up with one banner per pre-existing device of the user + // ("zich messages zur freigabe") which is the opposite of what we want: + // approval makes sense on the OLDER device judging the NEWER one, never + // the other way round. + const own = rows.find((r) => r.id === ctx.ownDeviceId); + const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY; + + for (const row of rows) { + if (!shouldSurface(row.id, ctx.ownDeviceId)) continue; + const rowCreatedAt = Date.parse(row.created_at); + if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) { + // Older / equal-age device — auto-treat as already approved on this + // side so it never re-prompts (covers reloads + future fetches). + persistApproved(row.id); + continue; + } + upsertPending(rowToPending(row)); + } +} + +// Starts the approval listener for the given user/device. Returns an +// unsubscribe function — call it on shell unmount to tear down the realtime +// channel and clear in-memory state. +export function startDeviceApprovalListener( + ctx: DeviceApprovalListenerCtx, +): () => void { + listenerCtx = ctx; + + void loadInitialPending(ctx); + + const channel = supabase + .channel(`device-approval:${ctx.ownUserId}`) + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'devices', + filter: `user_id=eq.${ctx.ownUserId}`, + }, + (payload: { new: Record }) => { + const row = payload.new as unknown as DeviceRowLite; + if (!row?.id) return; + if (!shouldSurface(row.id, ctx.ownDeviceId)) return; + upsertPending(rowToPending(row)); + }, + ) + .subscribe(); + + return () => { + void supabase.removeChannel(channel).catch(() => { + /* ignore — channel might already be gone */ + }); + pending = []; + listenerCtx = null; + notify(); + }; +} + +// Approves a pending device: walks every conversation the current user is +// in and writes a conv-key bundle for the new device. On success the request +// is removed from the pending list and the deviceId is persisted in +// localStorage so a reload doesn't re-prompt. +export async function approveDevice(req: PendingApproval): Promise { + const ctx = listenerCtx; + if (!ctx) { + throw new Error('deviceApproval: listener not started'); + } + if (req.userId !== ctx.ownUserId) { + // Phase 1 only handles same-user approvals (own new device). Friend-side + // approval is a later phase. + throw new Error('deviceApproval: cross-user approval not supported yet'); + } + + const priv = await ctx.getPriv(); + if (!priv) { + throw new Error('deviceApproval: own private key unavailable'); + } + + const sync: SyncCtx = { + myUserId: ctx.ownUserId, + myDeviceId: ctx.ownDeviceId, + priv, + }; + + try { + await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey); + } finally { + // Wipe the priv copy we asked for. The original lives in the secret + // store; this is the transient working copy. + for (let i = 0; i < priv.length; i++) priv[i] = 0; + } + + persistApproved(req.deviceId); + removePending(req.deviceId); +} + +// Denies a pending device: just remembers the deviceId in the dismissed-set +// and removes the request. No server-side change — the new device simply +// stays without any conv-key wraps until the user changes their mind (e.g. +// from a settings screen later). +export function denyDevice(deviceId: string): void { + persistDismissed(deviceId); + removePending(deviceId); +}