feat(crypto): sender-key per-conversation multi-device E2EE
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

This commit is contained in:
2026-04-19 19:27:24 +02:00
parent e57f81c9c3
commit 75618637e2
11 changed files with 703 additions and 142 deletions
+108
View File
@@ -0,0 +1,108 @@
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 INSERTs and, whenever a peer registers a
// new device that's in any of our conversations, wraps the active
// conversation key for the freshly-arrived device. This makes Sender-Key
// onboarding "just work" — the new device picks up the bundle from
// `conversation_keys` and can decrypt the entire history once at least one
// of our existing devices was online to do the wrapping.
//
// At-least-once delivery: if no existing device of any participant is online
// at the moment the new device joins, the new device stays unable to decrypt
// until SOMEONE comes online and runs this loop. Standard Signal trade-off.
export function startConversationKeySync(
ownUserId: string,
ownDeviceId: string,
): () => void {
let cancelled = false;
let priv: Uint8Array | null = null;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => {
priv = pk;
});
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;
// Skip our own devices — we don't need to send keys to ourselves
// (each install bootstraps its own keys via getOrCreateConvKey).
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key);
},
)
.subscribe();
async function wrapKeysForNewDevice(
myUserId: string,
myDeviceId: string,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubKeyHex: string,
) {
if (!priv) {
priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId);
if (!priv) return;
}
const newPub = pgHexToBytes(newDevicePubKeyHex);
const ownCtx: OwnDeviceCtx = {
userId: myUserId,
deviceId: myDeviceId,
privateKey: priv,
};
// Find conversations I'm in that the new device's user is also in.
const { data: shared, error: sErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', newDeviceUserId);
if (sErr) {
console.warn('keySync member lookup failed', sErr);
return;
}
const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string));
if (peerConvs.size === 0) return;
const { data: mine, error: mErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', myUserId)
.eq('accepted', true);
if (mErr) {
console.warn('keySync own-member lookup failed', mErr);
return;
}
const targets: string[] = [];
for (const row of mine ?? []) {
const id = row.conversation_id as string;
if (peerConvs.has(id)) targets.push(id);
}
for (const convId of targets) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
} catch (err: unknown) {
// Common: this device has no key for that conv yet (was offline at
// bootstrap). Other online devices will handle it.
console.warn('shareConvKeyToDevice failed', { convId, err });
}
}
}
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}
@@ -1,18 +1,16 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type ChatMessage,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
fetchOwnEnvelopes,
fetchSenderDeviceKeys,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { bytesToPgHex } from '@chat-app/shared/supabase';
import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { devLocalSecretStore } from './secretStore';
@@ -36,7 +34,7 @@ type MessageChangePayload = {
old: Record<string, unknown>;
};
function rowToMessage(row: Record<string, unknown>): ChatMessage {
function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
return {
id: String(row.id),
conversationId: String(row.conversation_id),
@@ -46,6 +44,9 @@ function rowToMessage(row: Record<string, unknown>): ChatMessage {
editedAt: row.edited_at ? String(row.edited_at) : null,
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
createdAt: String(row.created_at),
ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')),
nonce: pgHexToBytes(String(row.nonce ?? '\\x')),
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
};
}
@@ -66,23 +67,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}, [userId, deviceId]);
const decryptBatch = useCallback(
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
const ids = messages.map((m) => m.id);
const senderDeviceIds = messages
.map((m) => m.senderDeviceId)
.filter((v): v is string => v != null);
const [envelopes, senderKeys] = await Promise.all([
fetchOwnEnvelopes(supabase, ids, deviceId),
fetchSenderDeviceKeys(supabase, senderDeviceIds),
]);
return decryptMessages({
client: supabase,
messages,
envelopes,
senderKeys,
ownDeviceId: deviceId,
ownPrivateKey: priv,
});
},