b89a7e2617
Renames DecryptParams.ownDeviceId to ownUserId so decryptMessages actually looks up bundles by user. Sweeps remaining OwnDeviceCtx and loadDevicePrivateKey consumers in the desktop app to use cachedUserKey from userIdentity. Files scheduled for deletion in later tasks (BackupExportDialog, DeviceRestore, BackupRestoreDialog, BackupPromptBanner, deviceBackup, DeviceRegistration) are left untouched.
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
|
|
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
|
|
|
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 users.
|
|
//
|
|
// This fixes the "cannot decrypt" cliff for users that joined while no
|
|
// other participant 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 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 user.
|
|
void (() => {
|
|
void listMyConversationIds;
|
|
void listConversationMembers;
|
|
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 listConversationMembers(
|
|
conversationId: string,
|
|
): Promise<{ 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: keys, error: kErr } = await rawFrom('user_keys')
|
|
.select('user_id, public_key')
|
|
.in('user_id', userIds);
|
|
if (kErr) {
|
|
console.warn('keySync: user-keys lookup failed', kErr);
|
|
return [];
|
|
}
|
|
return (keys ?? []) as { 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_user_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_user_id: string }) => r.recipient_user_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 members = await listConversationMembers(convId);
|
|
if (members.length === 0) return;
|
|
|
|
const recipients = await listExistingKeyRecipients(convId, version);
|
|
const ownCtx: OwnUserCtx = {
|
|
userId: ctx.myUserId,
|
|
privateKey: ctx.priv,
|
|
};
|
|
|
|
for (const m of members) {
|
|
if (recipients.has(m.user_id)) continue;
|
|
// Skip our own user — 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 (m.user_id === ctx.myUserId) continue;
|
|
try {
|
|
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.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 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: shareConvKeyToUser gap-fill failed', {
|
|
convId,
|
|
recipient: m.user_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')
|
|
);
|
|
}
|
|
|
|
// Approval-flow helper. The legacy signature took (deviceId, userId,
|
|
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
|
|
// model only the user dimension matters, so the device-id parameter is
|
|
// ignored and the public key passed in MUST be the recipient user's
|
|
// user_keys.public_key (callers will be updated alongside the broader
|
|
// approval-flow rework).
|
|
export async function wrapForOneDevice(
|
|
ctx: SyncCtx,
|
|
newDeviceId: string,
|
|
newDeviceUserId: string,
|
|
newDevicePubHex: string,
|
|
): Promise<void> {
|
|
void newDeviceId; // kept for API compat; no longer used
|
|
|
|
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: OwnUserCtx = {
|
|
userId: ctx.myUserId,
|
|
privateKey: ctx.priv,
|
|
};
|
|
for (const convId of sharedConvs) {
|
|
try {
|
|
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
|
|
} catch (err: unknown) {
|
|
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
|
|
}
|
|
}
|
|
}
|