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 };
+1
View File
@@ -2,6 +2,7 @@ import type { AppSupabaseClient } from '../supabase/client.js';
export * from './attachments.js';
export * from './conversations.js';
export * from './convKeys.js';
export * from './groups.js';
export * from './messages.js';
export * from './types.js';
+93 -125
View File
@@ -1,15 +1,17 @@
import {
bytesToUtf8,
decryptFrom,
encryptFor,
utf8ToBytes,
} from '../crypto/index.js';
import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import {
decryptWithConvKey,
encryptWithConvKey,
getOrCreateConvKey,
type OwnDeviceCtx,
tryGetConvKey,
} from './convKeys.js';
import type { ChatMessage, DecryptedMessage } from './types.js';
const MESSAGE_COLS =
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at';
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version';
interface MessageRow {
id: string;
@@ -20,9 +22,18 @@ interface MessageRow {
edited_at: string | null;
deleted_at: string | null;
created_at: string;
ciphertext: string;
nonce: string;
key_version: number;
}
function mapMessage(row: MessageRow): ChatMessage {
interface MessageWithCipher extends ChatMessage {
ciphertext: Uint8Array;
nonce: Uint8Array;
keyVersion: number;
}
function mapMessage(row: MessageRow): MessageWithCipher {
return {
id: row.id,
conversationId: row.conversation_id,
@@ -32,6 +43,9 @@ function mapMessage(row: MessageRow): ChatMessage {
editedAt: row.edited_at,
deletedAt: row.deleted_at,
createdAt: row.created_at,
ciphertext: pgHexToBytes(row.ciphertext),
nonce: pgHexToBytes(row.nonce),
keyVersion: row.key_version,
};
}
@@ -85,14 +99,17 @@ export interface SendMessageParams {
attachmentHandles?: import('./attachments.js').AttachmentHandle[];
}
// Encrypts and inserts a message + per-device envelopes (one per recipient
// device, including the sender's own devices so multi-device sender devices
// can decrypt their own outbox).
// Encrypts and inserts a message using the shared per-conversation key
// (Sender-Key / Signal-style). The conv-key is generated lazily on first
// send and shared with every existing recipient device. New devices that
// register later receive their key bundle through `shareConvKeyToDevice`.
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
if (deviceKeys.length === 0) {
throw new Error('no recipient devices found');
}
const ownCtx: OwnDeviceCtx = {
userId: params.senderUserId,
deviceId: params.senderDeviceId,
privateKey: params.senderPrivateKey,
};
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
const attachments = params.attachmentHandles ?? [];
const payloadString =
@@ -101,11 +118,15 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
const plainBytes = utf8ToBytes(payloadString);
// Insert the message metadata first.
const cipher = encryptWithConvKey(plainBytes, handle.key);
const insertPayload: Record<string, unknown> = {
conversation_id: params.conversationId,
sender_id: params.senderUserId,
sender_device_id: params.senderDeviceId,
ciphertext: bytesToPgHex(cipher.ciphertext),
nonce: bytesToPgHex(cipher.nonce),
key_version: handle.keyVersion,
};
if (params.replyToId) insertPayload.reply_to_id = params.replyToId;
@@ -115,30 +136,7 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
.select(MESSAGE_COLS)
.single();
if (insertErr) throw insertErr;
const msg = mapMessage(messageRow as unknown as MessageRow);
// Encrypt one envelope per recipient device (including own devices).
const envelopes: { message_id: string; recipient_device_id: string; ciphertext: string; nonce: string }[] = [];
for (const dk of deviceKeys) {
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
envelopes.push({
message_id: msg.id,
recipient_device_id: dk.deviceId,
ciphertext: bytesToPgHex(ciphertext),
nonce: bytesToPgHex(nonce),
});
}
const { error: envErr } = await params.client
.from('message_envelopes')
.insert(envelopes as never);
if (envErr) {
// Best-effort cleanup if envelope insert failed.
await params.client.from('messages').delete().eq('id', msg.id);
throw envErr;
}
return msg;
return mapMessage(messageRow as unknown as MessageRow);
}
// Fetch the last `limit` messages of a conversation in ascending order.
@@ -146,7 +144,7 @@ export async function fetchConversationMessages(
client: AppSupabaseClient,
conversationId: string,
limit = 100,
): Promise<ChatMessage[]> {
): Promise<MessageWithCipher[]> {
const { data, error } = await client
.from('messages')
.select(MESSAGE_COLS)
@@ -158,47 +156,7 @@ export async function fetchConversationMessages(
return rows.map(mapMessage).reverse();
}
// Pull envelopes targeted at our own device for a batch of message ids.
export async function fetchOwnEnvelopes(
client: AppSupabaseClient,
messageIds: string[],
ownDeviceId: string,
): Promise<Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>> {
if (messageIds.length === 0) return new Map();
const { data, error } = await client
.from('message_envelopes')
.select('message_id, ciphertext, nonce')
.in('message_id', messageIds)
.eq('recipient_device_id', ownDeviceId);
if (error) throw error;
const out = new Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>();
for (const row of data ?? []) {
out.set(row.message_id, {
ciphertext: pgHexToBytes(row.ciphertext),
nonce: pgHexToBytes(row.nonce),
});
}
return out;
}
// Map sender device id -> public key (for verifying envelope authenticity).
export async function fetchSenderDeviceKeys(
client: AppSupabaseClient,
deviceIds: string[],
): Promise<Map<string, Uint8Array>> {
if (deviceIds.length === 0) return new Map();
const unique = Array.from(new Set(deviceIds));
const { data, error } = await client
.from('devices')
.select('id, public_key')
.in('id', unique);
if (error) throw error;
const out = new Map<string, Uint8Array>();
for (const row of data ?? []) {
out.set(row.id, pgHexToBytes(row.public_key));
}
return out;
}
export type { MessageWithCipher };
// ---------------------------------------------------------------------------
// Edit + delete
@@ -212,42 +170,30 @@ export interface EditMessageParams {
senderPrivateKey: Uint8Array;
}
// Re-encrypts the message for every currently-registered device in the
// conversation and rewrites the envelope rows. The server-side trigger
// enforces the 24h window + sender-only rule.
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
if (deviceKeys.length === 0) throw new Error('no recipient devices found');
// Re-encrypts the message body with the conv-key and updates the row.
// Server-side trigger enforces 24h window + sender-only rule.
export async function editEncryptedMessage(
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
): Promise<void> {
const ownCtx: OwnDeviceCtx = {
userId: params.senderUserId,
deviceId: params.senderDeviceId,
privateKey: params.senderPrivateKey,
};
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
const plainBytes = utf8ToBytes(params.newPlaintext);
const rows: {
message_id: string;
recipient_device_id: string;
ciphertext: string;
nonce: string;
}[] = [];
for (const dk of deviceKeys) {
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
rows.push({
message_id: params.messageId,
recipient_device_id: dk.deviceId,
ciphertext: bytesToPgHex(ciphertext),
nonce: bytesToPgHex(nonce),
});
}
const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key);
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
const { error: mErr } = await params.client
const { error } = await params.client
.from('messages')
.update({ edited_at: new Date().toISOString() } as never)
.update({
ciphertext: bytesToPgHex(cipher.ciphertext),
nonce: bytesToPgHex(cipher.nonce),
key_version: handle.keyVersion,
edited_at: new Date().toISOString(),
} as never)
.eq('id', params.messageId);
if (mErr) throw mErr;
// Upsert envelopes (INSERT on conflict UPDATE).
const { error: eErr } = await params.client
.from('message_envelopes')
.upsert(rows as never, { onConflict: 'message_id,recipient_device_id' });
if (eErr) throw eErr;
if (error) throw error;
}
export async function softDeleteMessage(
@@ -365,24 +311,46 @@ export async function removeReaction(
// Decrypt helpers
// ---------------------------------------------------------------------------
export interface DecryptOptions {
export interface DecryptParams {
client: AppSupabaseClient;
messages: MessageWithCipher[];
ownDeviceId: string;
ownPrivateKey: Uint8Array;
}
export async function decryptMessages(opts: {
messages: ChatMessage[];
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
senderKeys: Map<string, Uint8Array>;
ownPrivateKey: Uint8Array;
}): Promise<DecryptedMessage[]> {
// Decrypts messages using their conv-key (looked up + cached per
// keyVersion). Returns null `plaintext` when this device has no key bundle
// for that version yet (e.g. brand-new device waiting for share).
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
const out: DecryptedMessage[] = [];
// Group versions to avoid redundant lookups.
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
for (const m of opts.messages) {
const env = opts.envelopes.get(m.id);
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
let convCache = versions.get(m.conversationId);
if (!convCache) {
convCache = new Map();
versions.set(m.conversationId, convCache);
}
let key: Uint8Array | null;
if (convCache.has(m.keyVersion)) {
key = convCache.get(m.keyVersion) ?? null;
} else {
const handle = await tryGetConvKey(
opts.client,
m.conversationId,
opts.ownDeviceId,
opts.ownPrivateKey,
m.keyVersion,
);
key = handle?.key ?? null;
convCache.set(m.keyVersion, key);
}
let plaintext: string | null = null;
if (env && senderKey) {
if (key) {
try {
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
plaintext = bytesToUtf8(decoded);
} catch {
plaintext = null;