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
+277
View File
@@ -0,0 +1,277 @@
import {
decryptWithConvKey,
encryptWithConvKey,
generateConvKey,
unwrapConvKey,
wrapConvKeyForRecipient,
} from '../crypto/sessionKeys.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
// db-types in this monorepo is a static snapshot generated against the older
// schema. The new `conversation_keys` table + `active_key_version` column on
// `conversations` aren't in there yet. Until the codegen catches up we bypass
// the typed builder for those calls.
function rawFrom(client: AppSupabaseClient, table: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (client as unknown as { from: (t: string) => any }).from(table);
}
// Per-conversation symmetric key management. Replaces per-device envelopes
// with a single conv-key (32-byte XSalsa20-Poly1305) wrapped to each device's
// X25519 pubkey via crypto_box.
interface DeviceKey {
deviceId: string;
userId: string;
publicKey: Uint8Array;
}
export interface OwnDeviceCtx {
userId: string;
deviceId: string;
privateKey: Uint8Array;
}
export interface ConvKeyHandle {
conversationId: string;
keyVersion: number;
key: Uint8Array;
}
// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt.
const cache = new Map<string, ConvKeyHandle>();
const cacheKey = (convId: string, version: number) => convId + '@' + version;
export function clearConvKeyCache(): void {
cache.clear();
}
async function listDeviceKeys(
client: AppSupabaseClient,
conversationId: string,
): Promise<DeviceKey[]> {
const { data: members, error: mErr } = await client
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr) throw mErr;
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
if (memberIds.length === 0) return [];
const { data: devices, error: dErr } = await client
.from('devices')
.select('id, user_id, public_key')
.in('user_id', memberIds);
if (dErr) throw dErr;
return (devices ?? []).map((d) => ({
deviceId: d.id,
userId: d.user_id,
publicKey: pgHexToBytes(d.public_key),
}));
}
async function fetchActiveKeyVersion(
client: AppSupabaseClient,
conversationId: string,
): Promise<number> {
const { data, error } = await rawFrom(client, 'conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
if (error) throw error;
return (data as { active_key_version: number }).active_key_version;
}
interface SenderInfo {
senderDeviceId: string;
senderPublicKey: Uint8Array;
}
async function fetchKeyBundle(
client: AppSupabaseClient,
conversationId: string,
ownDeviceId: string,
keyVersion: number,
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
const { data, error } = await rawFrom(client, 'conversation_keys')
.select('encrypted_key, nonce, sender_device_id')
.eq('conversation_id', conversationId)
.eq('recipient_device_id', ownDeviceId)
.eq('key_version', keyVersion)
.maybeSingle();
if (error) throw error;
if (!data) return null;
const row = data as {
encrypted_key: string;
nonce: string;
sender_device_id: string;
};
const { data: dev, error: dErr } = await client
.from('devices')
.select('id, public_key')
.eq('id', row.sender_device_id)
.single();
if (dErr) throw dErr;
return {
encryptedKey: pgHexToBytes(row.encrypted_key),
nonce: pgHexToBytes(row.nonce),
sender: {
senderDeviceId: dev.id,
senderPublicKey: pgHexToBytes(dev.public_key),
},
};
}
// Bootstraps a brand-new conv-key, wrapping it for every member device that
// currently exists (including the caller's own devices). Used the first time
// a conversation needs a key, or when rotation is requested.
export async function bootstrapConvKey(
client: AppSupabaseClient,
conversationId: string,
own: OwnDeviceCtx,
keyVersion: number,
): Promise<ConvKeyHandle> {
const convKey = generateConvKey();
const recipients = await listDeviceKeys(client, conversationId);
if (recipients.length === 0) {
throw new Error('cannot bootstrap conv key — no recipient devices');
}
const rows: Array<{
conversation_id: string;
recipient_device_id: string;
key_version: number;
sender_device_id: string;
encrypted_key: string;
nonce: string;
}> = [];
for (const r of recipients) {
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
rows.push({
conversation_id: conversationId,
recipient_device_id: r.deviceId,
key_version: keyVersion,
sender_device_id: own.deviceId,
encrypted_key: bytesToPgHex(wrapped.ciphertext),
nonce: bytesToPgHex(wrapped.nonce),
});
}
const { error } = await rawFrom(client, 'conversation_keys').insert(rows);
if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
// Resolves the current conv-key for `conversationId`. Order:
// 1) cache hit
// 2) DB row for own device → unwrap
// 3) bootstrap a brand-new key (only valid path if NO existing keys exist
// for any device — i.e. this is the conversation's very first message)
export async function getOrCreateConvKey(
client: AppSupabaseClient,
conversationId: string,
own: OwnDeviceCtx,
): Promise<ConvKeyHandle> {
const version = await fetchActiveKeyVersion(client, conversationId);
const cached = cache.get(cacheKey(conversationId, version));
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version);
if (bundle) {
const key = await unwrapConvKey(
bundle.encryptedKey,
bundle.nonce,
bundle.sender.senderPublicKey,
own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
}
// No bundle yet for THIS device. Two cases:
// - I'm the first ever sender → bootstrap.
// - Conversation already has keys but my device wasn't included yet → I
// have to wait until an existing device wraps the key for me.
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
.select('recipient_device_id', { count: 'exact', head: true })
.eq('conversation_id', conversationId)
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
throw new Error(
'Awaiting conversation key — another device must share it with this device.',
);
}
return bootstrapConvKey(client, conversationId, own, version);
}
// Read-only variant: never bootstraps. Returns null if no key bundle exists
// for this device yet.
export async function tryGetConvKey(
client: AppSupabaseClient,
conversationId: string,
ownDeviceId: string,
ownPrivateKey: Uint8Array,
keyVersion: number,
): Promise<ConvKeyHandle | null> {
const cached = cache.get(cacheKey(conversationId, keyVersion));
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
bundle.encryptedKey,
bundle.nonce,
bundle.sender.senderPublicKey,
ownPrivateKey,
);
const handle = { conversationId, keyVersion, key };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
// Wraps the active conv-key for a single new device (e.g. when a peer
// registers a new device). The caller's device must have an unwrapped copy
// of the conv-key in cache (or be able to fetch it).
export async function shareConvKeyToDevice(
client: AppSupabaseClient,
conversationId: string,
recipientDeviceId: string,
recipientPublicKey: Uint8Array,
own: OwnDeviceCtx,
): Promise<void> {
const version = await fetchActiveKeyVersion(client, conversationId);
const handle =
cache.get(cacheKey(conversationId, version)) ??
(await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version));
if (!handle) {
throw new Error('cannot share conv key — own device does not have it yet');
}
const wrapped = await wrapConvKeyForRecipient(
handle.key,
recipientPublicKey,
own.privateKey,
);
const { error } = await rawFrom(client, 'conversation_keys').insert({
conversation_id: conversationId,
recipient_device_id: recipientDeviceId,
key_version: version,
sender_device_id: own.deviceId,
encrypted_key: bytesToPgHex(wrapped.ciphertext),
nonce: bytesToPgHex(wrapped.nonce),
});
if (error && !String(error.message ?? '').includes('duplicate')) throw error;
}
// Re-exports for convenience.
export { decryptWithConvKey, encryptWithConvKey };