diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index e45e208..6935caa 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
- "version": "0.2.1",
+ "version": "0.3.0",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx
index fc113f1..fa18e66 100644
--- a/apps/desktop/src/components/AppShell.tsx
+++ b/apps/desktop/src/components/AppShell.tsx
@@ -1,17 +1,25 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
export function AppShell() {
+ const { session, device } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
void ensureNotificationPermission();
}, []);
+ useEffect(() => {
+ if (!session?.user.id || !device?.id) return;
+ return startConversationKeySync(session.user.id, device.id);
+ }, [session?.user.id, device?.id]);
+
return (
diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx
index 4349c38..20d1e35 100644
--- a/apps/desktop/src/components/MessageBubble.tsx
+++ b/apps/desktop/src/components/MessageBubble.tsx
@@ -98,6 +98,8 @@ export function MessageBubble({
messageId: message.id,
conversationId,
newPlaintext: trimmed,
+ senderUserId: session.user.id,
+ senderDeviceId: device.id,
senderPrivateKey: priv,
});
setEditing(false);
diff --git a/apps/desktop/src/lib/conversationKeySync.ts b/apps/desktop/src/lib/conversationKeySync.ts
new file mode 100644
index 0000000..5e66762
--- /dev/null
+++ b/apps/desktop/src/lib/conversationKeySync.ts
@@ -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);
+ };
+}
diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts
index 5bec377..ff3a51a 100644
--- a/apps/desktop/src/lib/useConversationMessages.ts
+++ b/apps/desktop/src/lib/useConversationMessages.ts
@@ -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;
};
-function rowToMessage(row: Record): ChatMessage {
+function rowToMessage(row: Record): MessageWithCipher {
return {
id: String(row.id),
conversationId: String(row.conversation_id),
@@ -46,6 +44,9 @@ function rowToMessage(row: Record): 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 => {
+ async (messages: MessageWithCipher[]): Promise => {
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,
});
},
diff --git a/packages/shared/src/chat/convKeys.ts b/packages/shared/src/chat/convKeys.ts
new file mode 100644
index 0000000..eb02f7c
--- /dev/null
+++ b/packages/shared/src/chat/convKeys.ts
@@ -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();
+const cacheKey = (convId: string, version: number) => convId + '@' + version;
+
+export function clearConvKeyCache(): void {
+ cache.clear();
+}
+
+async function listDeviceKeys(
+ client: AppSupabaseClient,
+ conversationId: string,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts
index 3ef0809..b93434f 100644
--- a/packages/shared/src/chat/index.ts
+++ b/packages/shared/src/chat/index.ts
@@ -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';
diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts
index 87ad69b..451f949 100644
--- a/packages/shared/src/chat/messages.ts
+++ b/packages/shared/src/chat/messages.ts
@@ -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 {
- 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 = {
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 {
+): Promise {
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