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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user