Files
ChatApp/apps/desktop/src/lib/conversationKeySync.ts
T
byGalax 0ca29952ba
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
2026-04-19 23:04:03 +02:00

237 lines
7.9 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.
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 {
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);
};
}
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 });
}
}
}
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')
);
}
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 });
}
}
}