feat(crypto): sender-key per-conversation multi-device E2EE
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user