9b764053c4
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>
246 lines
8.7 KiB
TypeScript
246 lines
8.7 KiB
TypeScript
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
|
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
|
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
|
|
|
import { devLocalSecretStore } from './secretStore';
|
|
import { supabase } from './supabase';
|
|
|
|
// Watches the `devices` table for new entries AND, on mount, scans every
|
|
// conversation we participate in for missing key bundles. Fills gaps by
|
|
// re-wrapping our active conv-key for the missing recipient devices.
|
|
//
|
|
// This fixes the "cannot decrypt" cliff for devices that registered while
|
|
// no other participant device was online to share the key with them.
|
|
|
|
export interface SyncCtx {
|
|
myUserId: string;
|
|
myDeviceId: string;
|
|
priv: Uint8Array;
|
|
}
|
|
|
|
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
|
function rawFrom(table: string) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
|
}
|
|
|
|
// Module-level flag — gap-fill runs once per (user, device) combo per
|
|
// process lifetime. Page reloads / route changes don't re-trigger it.
|
|
const backfilledKey = new Set<string>();
|
|
|
|
export function startConversationKeySync(
|
|
ownUserId: string,
|
|
ownDeviceId: string,
|
|
): () => void {
|
|
// 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')
|
|
.select('conversation_id')
|
|
.eq('user_id', myUserId)
|
|
.eq('accepted', true);
|
|
if (error) {
|
|
console.warn('keySync: own-member lookup failed', error);
|
|
return [];
|
|
}
|
|
return (data ?? []).map((r) => r.conversation_id as string);
|
|
}
|
|
|
|
async function listConversationDevices(
|
|
conversationId: string,
|
|
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
|
const { data: members, error: mErr } = await supabase
|
|
.from('conversation_members')
|
|
.select('user_id')
|
|
.eq('conversation_id', conversationId)
|
|
.eq('accepted', true);
|
|
if (mErr) {
|
|
console.warn('keySync: members lookup failed', mErr);
|
|
return [];
|
|
}
|
|
const userIds = (members ?? []).map((m) => m.user_id as string);
|
|
if (userIds.length === 0) return [];
|
|
|
|
const { data: devices, error: dErr } = await supabase
|
|
.from('devices')
|
|
.select('id, user_id, public_key')
|
|
.in('user_id', userIds);
|
|
if (dErr) {
|
|
console.warn('keySync: devices lookup failed', dErr);
|
|
return [];
|
|
}
|
|
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
|
}
|
|
|
|
async function listExistingKeyRecipients(
|
|
conversationId: string,
|
|
keyVersion: number,
|
|
): Promise<Set<string>> {
|
|
const { data, error } = await rawFrom('conversation_keys')
|
|
.select('recipient_device_id')
|
|
.eq('conversation_id', conversationId)
|
|
.eq('key_version', keyVersion);
|
|
if (error) {
|
|
console.warn('keySync: existing keys lookup failed', error);
|
|
return new Set();
|
|
}
|
|
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
|
}
|
|
|
|
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
|
const { data, error } = await rawFrom('conversations')
|
|
.select('active_key_version')
|
|
.eq('id', conversationId)
|
|
.single();
|
|
if (error) {
|
|
console.warn('keySync: active key version lookup failed', error);
|
|
return 1;
|
|
}
|
|
return (data as { active_key_version: number }).active_key_version;
|
|
}
|
|
|
|
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
|
const convs = await listMyConversationIds(ctx.myUserId);
|
|
for (const convId of convs) {
|
|
try {
|
|
await syncOneConversationGaps(ctx, convId);
|
|
} catch (err: unknown) {
|
|
console.warn('keySync: conv gap sync failed', { convId, err });
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
const recipients = await listExistingKeyRecipients(convId, version);
|
|
const ownCtx: OwnDeviceCtx = {
|
|
userId: ctx.myUserId,
|
|
deviceId: ctx.myDeviceId,
|
|
privateKey: ctx.priv,
|
|
};
|
|
|
|
for (const dev of devices) {
|
|
if (recipients.has(dev.id)) continue;
|
|
// Skip our own device — we already have the bundle if we're capable of
|
|
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
|
if (dev.id === ctx.myDeviceId) continue;
|
|
try {
|
|
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
|
} catch (err: unknown) {
|
|
// Backfill is best-effort. Most common silent failures:
|
|
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
|
// - RLS rejects because the recipient's owner is a pending (not-yet
|
|
// accepted) DM member, or was removed from the conv.
|
|
// Both are recoverable / expected, swallow without spam.
|
|
if (isExpectedShareFailure(err)) continue;
|
|
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
|
convId,
|
|
recipient: dev.id,
|
|
err,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function isExpectedShareFailure(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as { message?: string; code?: string; details?: string; status?: number };
|
|
const code = (e.code ?? '').toString();
|
|
const status = e.status;
|
|
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
|
|
return (
|
|
status === 403 ||
|
|
code === '42501' || // postgres: insufficient_privilege (RLS)
|
|
code === '23505' || // unique_violation
|
|
haystack.includes('row-level security') ||
|
|
haystack.includes('does not have it yet') ||
|
|
haystack.includes('Forbidden')
|
|
);
|
|
}
|
|
|
|
export async function wrapForOneDevice(
|
|
ctx: SyncCtx,
|
|
newDeviceId: string,
|
|
newDeviceUserId: string,
|
|
newDevicePubHex: string,
|
|
): Promise<void> {
|
|
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
|
const { data: peerMember, error: pErr } = await supabase
|
|
.from('conversation_members')
|
|
.select('conversation_id')
|
|
.eq('user_id', newDeviceUserId);
|
|
if (pErr) {
|
|
console.warn('keySync: peer-member lookup failed', pErr);
|
|
return;
|
|
}
|
|
const sharedConvs = (peerMember ?? [])
|
|
.map((r) => r.conversation_id as string)
|
|
.filter((id) => myConvs.has(id));
|
|
if (sharedConvs.length === 0) return;
|
|
|
|
const newPub = pgHexToBytes(newDevicePubHex);
|
|
const ownCtx: OwnDeviceCtx = {
|
|
userId: ctx.myUserId,
|
|
deviceId: ctx.myDeviceId,
|
|
privateKey: ctx.priv,
|
|
};
|
|
for (const convId of sharedConvs) {
|
|
try {
|
|
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
|
} catch (err: unknown) {
|
|
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
|
}
|
|
}
|
|
}
|