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);
};
}