feat(crypto): explicit device-approval flow + dev userData isolation

Disable the previous auto-share of conversation keys to newly-registered
devices: a stolen password / new device registered by an attacker no
longer automatically grants history access. Backup-Restore (which
restores the old device-id) still opens existing wraps as before.

Phase 1 of the approval replacement:
- New `lib/deviceApproval.ts`: realtime listener for `devices` INSERT,
  surfaces a pending list, persists approve/deny decisions in
  `chatapp.approvedDeviceIds` / `chatapp.dismissedDeviceIds`. Filters the
  initial fetch by created_at > own-device's created_at so a freshly
  installed client doesn't try to "approve" pre-existing devices.
- New `components/DeviceApprovalBanner.tsx`: bottom-right Discord-style
  banner per pending request with Genehmigen / Ablehnen actions; reuses
  `wrapForOneDevice` from conversationKeySync to fan out conv-keys.
- AppShell mounts both the listener and the banner.

Plus dev userData isolation in main.ts: when running unpackaged, append
`-Dev` to the userData path so `pnpm dev` runs side-by-side with the
installed packaged build instead of colliding on the single-instance
lock. Window title also distinguished as "ChatApp (Dev)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-07 17:03:04 +02:00
parent 950ef5b706
commit 9b764053c4
5 changed files with 531 additions and 43 deletions
+17 -1
View File
@@ -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() {
</div>
<CallUI />
<BackupPromptBanner />
<DeviceApprovalBanner />
</div>
);
}
@@ -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<PendingApproval[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [errorId, setErrorId] = useState<string | null>(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 (
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3">
{pending.map((req) => {
const busy = busyId === req.deviceId;
const errored = errorId === req.deviceId;
return (
<div
key={req.deviceId}
role="alertdialog"
aria-labelledby={`device-approval-${req.deviceId}-title`}
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-line bg-surface-3 p-4 text-sm text-fg shadow-xl backdrop-blur-md"
>
<LockIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p
id={`device-approval-${req.deviceId}-title`}
className="font-semibold"
>
{t('app:device_approval.title', {
defaultValue: 'Neues Gerät registriert',
})}
</p>
<p className="mt-0.5 text-xs text-fg-muted">
{formatDeviceLabel(req)} ·{' '}
{formatRelativeTime(req.createdAt, i18n.language)}
</p>
<p className="mt-1 text-xs text-fg-muted">
{t('app:device_approval.question', {
defaultValue: 'War das du?',
})}
</p>
{errored && (
<p className="mt-1 text-xs text-red-500">
{t('app:device_approval.error', {
defaultValue:
'Genehmigung fehlgeschlagen. Versuch es nochmal.',
})}
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => void onApprove(req)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5 animate-spin" />}
{t('app:device_approval.approve', {
defaultValue: 'Genehmigen',
})}
</button>
<button
type="button"
disabled={busy}
onClick={() => onDeny(req.deviceId)}
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
>
{t('app:device_approval.deny', {
defaultValue: 'Ablehnen',
})}
</button>
</div>
</div>
<button
type="button"
disabled={busy}
onClick={() => onDeny(req.deviceId)}
aria-label={t('app:device_approval.dismiss', {
defaultValue: 'Schließen',
})}
className="cursor-pointer text-fg-muted transition hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
>
<XIcon className="h-4 w-4" />
</button>
</div>
);
})}
</div>
);
}
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;
}
}