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
+50 -41
View File
@@ -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<string[]> {
const { data, error } = await supabase
.from('conversation_members')
@@ -149,7 +158,7 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
}
}
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
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,