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