refactor: swap device-id contexts for user-id contexts at call sites

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.
This commit is contained in:
byGalax
2026-05-15 22:41:44 +02:00
parent 8d69329763
commit b89a7e2617
7 changed files with 57 additions and 65 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
@@ -6,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync'; import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureNotificationPermission } from '../lib/osNotify'; import { ensureNotificationPermission } from '../lib/osNotify';
import { devLocalSecretStore } from '../lib/secretStore'; import { cachedUserKey } from '../lib/userIdentity';
import { BackupPromptBanner } from './BackupPromptBanner'; import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI'; import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner'; import { DeviceApprovalBanner } from './DeviceApprovalBanner';
@@ -28,7 +27,7 @@ export function AppShell() {
const stopApproval = startDeviceApprovalListener({ const stopApproval = startDeviceApprovalListener({
ownUserId: userId, ownUserId: userId,
ownDeviceId: deviceId, ownDeviceId: deviceId,
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId), getPriv: () => cachedUserKey(userId),
}); });
return () => { return () => {
stopKeySync(); stopKeySync();
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { import {
type AttachmentHandle, type AttachmentHandle,
type DecryptedMessage, type DecryptedMessage,
@@ -15,8 +14,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons'; import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
@@ -89,8 +88,8 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('private key not loaded'); if (!priv) throw new Error('user key not unlocked');
const hasAttachments = sourceAttachments.length > 0; const hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : ''); const text = preview || (hasAttachments ? '' : '');
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { import {
type DecryptedMessage, type DecryptedMessage,
editEncryptedMessage, editEncryptedMessage,
@@ -11,8 +10,8 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { summarizePollVotes } from '../lib/conversationFeatures'; import { summarizePollVotes } from '../lib/conversationFeatures';
import { extractFirstUrl } from '../lib/useLinkPreview'; import { extractFirstUrl } from '../lib/useLinkPreview';
@@ -201,8 +200,8 @@ export function MessageBubble({
setBusy(true); setBusy(true);
setEditError(null); setEditError(null);
try { try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('private key not loaded'); if (!priv) throw new Error('user key not unlocked');
await editEncryptedMessage({ await editEncryptedMessage({
client: supabase, client: supabase,
messageId: message.id, messageId: message.id,
+2 -7
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat'; import { sendEncryptedMessage } from '@chat-app/shared/chat';
import { import {
type CallKind, type CallKind,
@@ -114,8 +113,8 @@ import {
type ScreenSharePreset, type ScreenSharePreset,
updateScreenShareSettings, updateScreenShareSettings,
} from '../lib/screenShareSettings'; } from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen'; import { setWindowFullscreen } from '../lib/windowFullscreen';
export type CallState = export type CallState =
@@ -448,11 +447,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
) => { ) => {
if (!session?.user.id || !device?.id) return; if (!session?.user.id || !device?.id) return;
try { try {
const priv = await loadDevicePrivateKey( const priv = await cachedUserKey(session.user.id);
devLocalSecretStore,
session.user.id,
device.id,
);
if (!priv) return; if (!priv) return;
const payload = JSON.stringify({ const payload = JSON.stringify({
v: 1, v: 1,
+37 -36
View File
@@ -1,16 +1,14 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
import { pgHexToBytes } from '@chat-app/shared/supabase'; import { pgHexToBytes } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase'; import { supabase } from './supabase';
// Watches the `devices` table for new entries AND, on mount, scans every // Watches the `devices` table for new entries AND, on mount, scans every
// conversation we participate in for missing key bundles. Fills gaps by // conversation we participate in for missing key bundles. Fills gaps by
// re-wrapping our active conv-key for the missing recipient devices. // re-wrapping our active conv-key for the missing recipient users.
// //
// This fixes the "cannot decrypt" cliff for devices that registered while // This fixes the "cannot decrypt" cliff for users that joined while no
// no other participant device was online to share the key with them. // other participant was online to share the key with them.
export interface SyncCtx { export interface SyncCtx {
myUserId: string; myUserId: string;
@@ -57,8 +55,6 @@ export function startConversationKeySync(
void ownUserId; void ownUserId;
void ownDeviceId; void ownDeviceId;
void backfilledKey; void backfilledKey;
void loadDevicePrivateKey;
void devLocalSecretStore;
void supabase; void supabase;
return () => {}; return () => {};
} }
@@ -70,10 +66,10 @@ export function startConversationKeySync(
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for // `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
// the device-approval module — once the user explicitly approves a new // the device-approval module — once the user explicitly approves a new
// device the approval flow re-uses these helpers to wrap conv-keys for // device the approval flow re-uses these helpers to wrap conv-keys for
// that specific deviceId. // that specific user.
void (() => { void (() => {
void listMyConversationIds; void listMyConversationIds;
void listConversationDevices; void listConversationMembers;
void listExistingKeyRecipients; void listExistingKeyRecipients;
void getActiveKeyVersion; void getActiveKeyVersion;
void syncAllExistingGaps; void syncAllExistingGaps;
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
return (data ?? []).map((r) => r.conversation_id as string); return (data ?? []).map((r) => r.conversation_id as string);
} }
async function listConversationDevices( async function listConversationMembers(
conversationId: string, conversationId: string,
): Promise<{ id: string; user_id: string; public_key: string }[]> { ): Promise<{ user_id: string; public_key: string }[]> {
const { data: members, error: mErr } = await supabase const { data: members, error: mErr } = await supabase
.from('conversation_members') .from('conversation_members')
.select('user_id') .select('user_id')
@@ -109,15 +105,14 @@ async function listConversationDevices(
const userIds = (members ?? []).map((m) => m.user_id as string); const userIds = (members ?? []).map((m) => m.user_id as string);
if (userIds.length === 0) return []; if (userIds.length === 0) return [];
const { data: devices, error: dErr } = await supabase const { data: keys, error: kErr } = await rawFrom('user_keys')
.from('devices') .select('user_id, public_key')
.select('id, user_id, public_key')
.in('user_id', userIds); .in('user_id', userIds);
if (dErr) { if (kErr) {
console.warn('keySync: devices lookup failed', dErr); console.warn('keySync: user-keys lookup failed', kErr);
return []; return [];
} }
return (devices ?? []) as { id: string; user_id: string; public_key: string }[]; return (keys ?? []) as { user_id: string; public_key: string }[];
} }
async function listExistingKeyRecipients( async function listExistingKeyRecipients(
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
keyVersion: number, keyVersion: number,
): Promise<Set<string>> { ): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys') const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_id') .select('recipient_user_id')
.eq('conversation_id', conversationId) .eq('conversation_id', conversationId)
.eq('key_version', keyVersion); .eq('key_version', keyVersion);
if (error) { if (error) {
console.warn('keySync: existing keys lookup failed', error); console.warn('keySync: existing keys lookup failed', error);
return new Set(); return new Set();
} }
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id)); return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
} }
async function getActiveKeyVersion(conversationId: string): Promise<number> { async function getActiveKeyVersion(conversationId: string): Promise<number> {
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
export 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 version = await getActiveKeyVersion(convId);
const devices = await listConversationDevices(convId); const members = await listConversationMembers(convId);
if (devices.length === 0) return; if (members.length === 0) return;
const recipients = await listExistingKeyRecipients(convId, version); const recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = { const ownCtx: OwnUserCtx = {
userId: ctx.myUserId, userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv, privateKey: ctx.priv,
}; };
for (const dev of devices) { for (const m of members) {
if (recipients.has(dev.id)) continue; if (recipients.has(m.user_id)) continue;
// Skip our own device — we already have the bundle if we're capable of // 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). // sharing (or don't need it if we ourselves haven't been wrapped yet).
if (dev.id === ctx.myDeviceId) continue; if (m.user_id === ctx.myUserId) continue;
try { try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx); await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
} catch (err: unknown) { } catch (err: unknown) {
// Backfill is best-effort. Most common silent failures: // Backfill is best-effort. Most common silent failures:
// - tryGetConvKey couldn't unwrap (another peer will fill the gap). // - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// - RLS rejects because the recipient's owner is a pending (not-yet // - RLS rejects because the recipient is a pending (not-yet
// accepted) DM member, or was removed from the conv. // accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam. // Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue; if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', { console.warn('keySync: shareConvKeyToUser gap-fill failed', {
convId, convId,
recipient: dev.id, recipient: m.user_id,
err, err,
}); });
} }
@@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
); );
} }
// 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( export async function wrapForOneDevice(
ctx: SyncCtx, ctx: SyncCtx,
newDeviceId: string, newDeviceId: string,
newDeviceUserId: string, newDeviceUserId: string,
newDevicePubHex: string, newDevicePubHex: string,
): Promise<void> { ): Promise<void> {
void newDeviceId; // kept for API compat; no longer used
const myConvs = new Set(await listMyConversationIds(ctx.myUserId)); const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
const { data: peerMember, error: pErr } = await supabase const { data: peerMember, error: pErr } = await supabase
.from('conversation_members') .from('conversation_members')
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
if (sharedConvs.length === 0) return; if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex); const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = { const ownCtx: OwnUserCtx = {
userId: ctx.myUserId, userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv, privateKey: ctx.priv,
}; };
for (const convId of sharedConvs) { for (const convId of sharedConvs) {
try { try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx); await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
} catch (err: unknown) { } catch (err: unknown) {
console.warn('keySync: shareConvKeyToDevice failed', { convId, err }); console.warn('keySync: shareConvKeyToUser failed', { convId, err });
} }
} }
} }
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { import {
type AttachmentHandle, type AttachmentHandle,
type DecryptedMessage, type DecryptedMessage,
@@ -30,8 +29,8 @@ import {
shouldGiveUp, shouldGiveUp,
subscribeOutbox, subscribeOutbox,
} from './messageOutbox'; } from './messageOutbox';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase'; import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity';
interface State { interface State {
messages: DecryptedMessage[]; messages: DecryptedMessage[];
@@ -90,25 +89,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}); });
}, [conversationId]); }, [conversationId]);
// Load own private key once per (user, device). // Load own user private key once per user.
useEffect(() => { useEffect(() => {
privateKeyRef.current = null; privateKeyRef.current = null;
if (!userId || !deviceId) return; if (!userId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => { void cachedUserKey(userId).then((pk) => {
privateKeyRef.current = pk; privateKeyRef.current = pk;
}); });
}, [userId, deviceId]); }, [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;
if (!priv || !deviceId || messages.length === 0) { if (!priv || !userId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null })); return messages.map((m) => ({ ...m, plaintext: null }));
} }
return decryptMessages({ return decryptMessages({
client: supabase, client: supabase,
messages, messages,
ownDeviceId: deviceId, ownUserId: userId,
ownPrivateKey: priv, ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so // Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial // the main thread stays responsive during bulk operations (initial
@@ -116,7 +115,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
aeadBatchDelegate: decryptBatchWorker, aeadBatchDelegate: decryptBatchWorker,
}); });
}, },
[deviceId], [userId],
); );
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
+2 -2
View File
@@ -440,7 +440,7 @@ export async function removeReaction(
export interface DecryptParams { export interface DecryptParams {
client: AppSupabaseClient; client: AppSupabaseClient;
messages: MessageWithCipher[]; messages: MessageWithCipher[];
ownDeviceId: string; ownUserId: string;
ownPrivateKey: Uint8Array; ownPrivateKey: Uint8Array;
/** /**
* Optional delegate that performs the symmetric-decrypt + utf-8 decode * Optional delegate that performs the symmetric-decrypt + utf-8 decode
@@ -483,7 +483,7 @@ export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMes
const handle = await tryGetConvKey( const handle = await tryGetConvKey(
opts.client, opts.client,
m.conversationId, m.conversationId,
opts.ownDeviceId, opts.ownUserId,
opts.ownPrivateKey, opts.ownPrivateKey,
m.keyVersion, m.keyVersion,
); );