feat(desktop): proactively rewrap conv-keys for un-migrated peers on open
When a conversation opens, the local client checks every accepted member for a recipient_user_id bundle on the active key version. Members without one get a best-effort wrap from the local conv-key handle. This closes the legacy migration gap where peer B couldn't read because no one had yet wrapped the new per-user conv-key for them.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||||||
import {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
@@ -8,6 +9,8 @@ import {
|
|||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
|
shareConvKeyToUser,
|
||||||
|
tryGetConvKey,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -98,6 +101,96 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
||||||
|
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
||||||
|
// for them. Members who are missing one (typically peers who haven't yet
|
||||||
|
// migrated to the per-user key model) get a best-effort wrap from the
|
||||||
|
// local conv-key handle. Closes the legacy migration gap so peer B can
|
||||||
|
// read on first unlock without manual intervention from A.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId || !userId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const run = async (): Promise<void> => {
|
||||||
|
// Wait for the private key ref to be populated. Loop with backoff
|
||||||
|
// because it's set asynchronously by another effect; bail if the
|
||||||
|
// conversation switches.
|
||||||
|
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
const priv = privateKeyRef.current;
|
||||||
|
if (!priv || cancelled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: convRow, error: convErr } = await supabase
|
||||||
|
.from('conversations')
|
||||||
|
.select('active_key_version')
|
||||||
|
.eq('id', conversationId)
|
||||||
|
.single();
|
||||||
|
if (convErr || !convRow) return;
|
||||||
|
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||||
|
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||||
|
|
||||||
|
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||||
|
if (!handle || cancelled) return;
|
||||||
|
|
||||||
|
const { data: members, error: mErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('user_id, accepted')
|
||||||
|
.eq('conversation_id', conversationId);
|
||||||
|
if (mErr || !members) return;
|
||||||
|
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||||
|
.filter((m) => m.accepted && m.user_id !== userId)
|
||||||
|
.map((m) => m.user_id);
|
||||||
|
if (memberIds.length === 0) return;
|
||||||
|
|
||||||
|
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||||||
|
for (const peer of peers) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const { count, error: cntErr } = await (
|
||||||
|
supabase as unknown as {
|
||||||
|
from: (t: string) => {
|
||||||
|
select: (s: string, o?: object) => {
|
||||||
|
eq: (...a: unknown[]) => {
|
||||||
|
eq: (...a: unknown[]) => {
|
||||||
|
eq: (
|
||||||
|
...a: unknown[]
|
||||||
|
) => Promise<{ count: number | null; error: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.from('conversation_keys')
|
||||||
|
.select('recipient_user_id', { count: 'exact', head: true })
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('recipient_user_id', peer.userId)
|
||||||
|
.eq('key_version', version);
|
||||||
|
if (cntErr) continue;
|
||||||
|
if ((count ?? 0) === 0) {
|
||||||
|
try {
|
||||||
|
await shareConvKeyToUser(
|
||||||
|
supabase,
|
||||||
|
conversationId,
|
||||||
|
peer.userId,
|
||||||
|
peer.publicKey,
|
||||||
|
{ userId, privateKey: priv },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('proactive rewrap failed for', peer.userId, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('proactive rewrap sweep failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId, userId]);
|
||||||
|
|
||||||
const decryptBatch = useCallback(
|
const decryptBatch = useCallback(
|
||||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
|
|||||||
Reference in New Issue
Block a user