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>
);
}