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 { Outlet } from 'react-router-dom';
@@ -6,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureNotificationPermission } from '../lib/osNotify';
import { devLocalSecretStore } from '../lib/secretStore';
import { cachedUserKey } from '../lib/userIdentity';
import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
@@ -28,7 +27,7 @@ export function AppShell() {
const stopApproval = startDeviceApprovalListener({
ownUserId: userId,
ownDeviceId: deviceId,
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
getPriv: () => cachedUserKey(userId),
});
return () => {
stopKeySync();
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
@@ -15,8 +14,8 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
@@ -89,8 +88,8 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
setBusy(true);
setError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
const hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : '');
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
@@ -11,8 +10,8 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { summarizePollVotes } from '../lib/conversationFeatures';
import { extractFirstUrl } from '../lib/useLinkPreview';
@@ -201,8 +200,8 @@ export function MessageBubble({
setBusy(true);
setEditError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
await editEncryptedMessage({
client: supabase,
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 {
type CallKind,
@@ -114,8 +113,8 @@ import {
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
export type CallState =
@@ -448,11 +447,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
) => {
if (!session?.user.id || !device?.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
const priv = await cachedUserKey(session.user.id);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
+37 -36
View File
@@ -1,16 +1,14 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
import { type OwnUserCtx, shareConvKeyToUser } 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.
// re-wrapping our active conv-key for the missing recipient users.
//
// This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them.
// 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;
@@ -57,8 +55,6 @@ export function startConversationKeySync(
void ownUserId;
void ownDeviceId;
void backfilledKey;
void loadDevicePrivateKey;
void devLocalSecretStore;
void supabase;
return () => {};
}
@@ -70,10 +66,10 @@ export function startConversationKeySync(
// `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.
// that specific user.
void (() => {
void listMyConversationIds;
void listConversationDevices;
void listConversationMembers;
void listExistingKeyRecipients;
void getActiveKeyVersion;
void syncAllExistingGaps;
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
return (data ?? []).map((r) => r.conversation_id as string);
}
async function listConversationDevices(
async function listConversationMembers(
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
.from('conversation_members')
.select('user_id')
@@ -109,15 +105,14 @@ async function listConversationDevices(
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')
const { data: keys, error: kErr } = await rawFrom('user_keys')
.select('user_id, public_key')
.in('user_id', userIds);
if (dErr) {
console.warn('keySync: devices lookup failed', dErr);
if (kErr) {
console.warn('keySync: user-keys lookup failed', kErr);
return [];
}
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
return (keys ?? []) as { user_id: string; public_key: string }[];
}
async function listExistingKeyRecipients(
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
keyVersion: number,
): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_id')
.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_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> {
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
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 members = await listConversationMembers(convId);
if (members.length === 0) return;
const recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
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
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 (dev.id === ctx.myDeviceId) continue;
if (m.user_id === ctx.myUserId) continue;
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) {
// 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
// - 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: shareConvKeyToDevice gap-fill failed', {
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
convId,
recipient: dev.id,
recipient: m.user_id,
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(
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')
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const convId of sharedConvs) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
} 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 {
type AttachmentHandle,
type DecryptedMessage,
@@ -30,8 +29,8 @@ import {
shouldGiveUp,
subscribeOutbox,
} from './messageOutbox';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity';
interface State {
messages: DecryptedMessage[];
@@ -90,25 +89,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [conversationId]);
// Load own private key once per (user, device).
// Load own user private key once per user.
useEffect(() => {
privateKeyRef.current = null;
if (!userId || !deviceId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
if (!userId) return;
void cachedUserKey(userId).then((pk) => {
privateKeyRef.current = pk;
});
}, [userId, deviceId]);
}, [userId]);
const decryptBatch = useCallback(
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
if (!priv || !userId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
return decryptMessages({
client: supabase,
messages,
ownDeviceId: deviceId,
ownUserId: userId,
ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial
@@ -116,7 +115,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
[userId],
);
const refresh = useCallback(async () => {
+2 -2
View File
@@ -440,7 +440,7 @@ export async function removeReaction(
export interface DecryptParams {
client: AppSupabaseClient;
messages: MessageWithCipher[];
ownDeviceId: string;
ownUserId: string;
ownPrivateKey: Uint8Array;
/**
* 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(
opts.client,
m.conversationId,
opts.ownDeviceId,
opts.ownUserId,
opts.ownPrivateKey,
m.keyVersion,
);