feat(crypto): sender-key per-conversation multi-device E2EE
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
const { session, device } = useAuth();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||||
// re-asking if the user already responded this session.
|
// re-asking if the user already responded this session.
|
||||||
void ensureNotificationPermission();
|
void ensureNotificationPermission();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.user.id || !device?.id) return;
|
||||||
|
return startConversationKeySync(session.user.id, device.id);
|
||||||
|
}, [session?.user.id, device?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
||||||
<ShellBackground />
|
<ShellBackground />
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function MessageBubble({
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
newPlaintext: trimmed,
|
newPlaintext: trimmed,
|
||||||
|
senderUserId: session.user.id,
|
||||||
|
senderDeviceId: device.id,
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
import {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
type ChatMessage,
|
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
decryptMessages,
|
decryptMessages,
|
||||||
encryptAndUploadAttachment,
|
encryptAndUploadAttachment,
|
||||||
fetchConversationMessages,
|
fetchConversationMessages,
|
||||||
fetchOwnEnvelopes,
|
|
||||||
fetchSenderDeviceKeys,
|
|
||||||
insertAttachmentRow,
|
insertAttachmentRow,
|
||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
|
type MessageWithCipher,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
} from '@chat-app/shared/chat';
|
} 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { devLocalSecretStore } from './secretStore';
|
import { devLocalSecretStore } from './secretStore';
|
||||||
@@ -36,7 +34,7 @@ type MessageChangePayload = {
|
|||||||
old: Record<string, unknown>;
|
old: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||||
return {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
conversationId: String(row.conversation_id),
|
conversationId: String(row.conversation_id),
|
||||||
@@ -46,6 +44,9 @@ function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
|||||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||||
createdAt: String(row.created_at),
|
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]);
|
}, [userId, deviceId]);
|
||||||
|
|
||||||
const decryptBatch = useCallback(
|
const decryptBatch = useCallback(
|
||||||
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
|
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
if (!priv || !deviceId || messages.length === 0) {
|
if (!priv || !deviceId || messages.length === 0) {
|
||||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
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({
|
return decryptMessages({
|
||||||
|
client: supabase,
|
||||||
messages,
|
messages,
|
||||||
envelopes,
|
ownDeviceId: deviceId,
|
||||||
senderKeys,
|
|
||||||
ownPrivateKey: priv,
|
ownPrivateKey: priv,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -2,6 +2,7 @@ import type { AppSupabaseClient } from '../supabase/client.js';
|
|||||||
|
|
||||||
export * from './attachments.js';
|
export * from './attachments.js';
|
||||||
export * from './conversations.js';
|
export * from './conversations.js';
|
||||||
|
export * from './convKeys.js';
|
||||||
export * from './groups.js';
|
export * from './groups.js';
|
||||||
export * from './messages.js';
|
export * from './messages.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import {
|
import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
|
||||||
bytesToUtf8,
|
|
||||||
decryptFrom,
|
|
||||||
encryptFor,
|
|
||||||
utf8ToBytes,
|
|
||||||
} from '../crypto/index.js';
|
|
||||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||||
import type { AppSupabaseClient } from '../supabase/client.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';
|
import type { ChatMessage, DecryptedMessage } from './types.js';
|
||||||
|
|
||||||
const MESSAGE_COLS =
|
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 {
|
interface MessageRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,9 +22,18 @@ interface MessageRow {
|
|||||||
edited_at: string | null;
|
edited_at: string | null;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
created_at: string;
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
conversationId: row.conversation_id,
|
conversationId: row.conversation_id,
|
||||||
@@ -32,6 +43,9 @@ function mapMessage(row: MessageRow): ChatMessage {
|
|||||||
editedAt: row.edited_at,
|
editedAt: row.edited_at,
|
||||||
deletedAt: row.deleted_at,
|
deletedAt: row.deleted_at,
|
||||||
createdAt: row.created_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[];
|
attachmentHandles?: import('./attachments.js').AttachmentHandle[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypts and inserts a message + per-device envelopes (one per recipient
|
// Encrypts and inserts a message using the shared per-conversation key
|
||||||
// device, including the sender's own devices so multi-device sender devices
|
// (Sender-Key / Signal-style). The conv-key is generated lazily on first
|
||||||
// can decrypt their own outbox).
|
// 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> {
|
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
|
||||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
const ownCtx: OwnDeviceCtx = {
|
||||||
if (deviceKeys.length === 0) {
|
userId: params.senderUserId,
|
||||||
throw new Error('no recipient devices found');
|
deviceId: params.senderDeviceId,
|
||||||
}
|
privateKey: params.senderPrivateKey,
|
||||||
|
};
|
||||||
|
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||||
|
|
||||||
const attachments = params.attachmentHandles ?? [];
|
const attachments = params.attachmentHandles ?? [];
|
||||||
const payloadString =
|
const payloadString =
|
||||||
@@ -101,11 +118,15 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
|||||||
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
|
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
|
||||||
const plainBytes = utf8ToBytes(payloadString);
|
const plainBytes = utf8ToBytes(payloadString);
|
||||||
|
|
||||||
// Insert the message metadata first.
|
const cipher = encryptWithConvKey(plainBytes, handle.key);
|
||||||
|
|
||||||
const insertPayload: Record<string, unknown> = {
|
const insertPayload: Record<string, unknown> = {
|
||||||
conversation_id: params.conversationId,
|
conversation_id: params.conversationId,
|
||||||
sender_id: params.senderUserId,
|
sender_id: params.senderUserId,
|
||||||
sender_device_id: params.senderDeviceId,
|
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;
|
if (params.replyToId) insertPayload.reply_to_id = params.replyToId;
|
||||||
|
|
||||||
@@ -115,30 +136,7 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
|||||||
.select(MESSAGE_COLS)
|
.select(MESSAGE_COLS)
|
||||||
.single();
|
.single();
|
||||||
if (insertErr) throw insertErr;
|
if (insertErr) throw insertErr;
|
||||||
const msg = mapMessage(messageRow as unknown as MessageRow);
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the last `limit` messages of a conversation in ascending order.
|
// Fetch the last `limit` messages of a conversation in ascending order.
|
||||||
@@ -146,7 +144,7 @@ export async function fetchConversationMessages(
|
|||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
): Promise<ChatMessage[]> {
|
): Promise<MessageWithCipher[]> {
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select(MESSAGE_COLS)
|
.select(MESSAGE_COLS)
|
||||||
@@ -158,47 +156,7 @@ export async function fetchConversationMessages(
|
|||||||
return rows.map(mapMessage).reverse();
|
return rows.map(mapMessage).reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull envelopes targeted at our own device for a batch of message ids.
|
export type { MessageWithCipher };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Edit + delete
|
// Edit + delete
|
||||||
@@ -212,42 +170,30 @@ export interface EditMessageParams {
|
|||||||
senderPrivateKey: Uint8Array;
|
senderPrivateKey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-encrypts the message for every currently-registered device in the
|
// Re-encrypts the message body with the conv-key and updates the row.
|
||||||
// conversation and rewrites the envelope rows. The server-side trigger
|
// Server-side trigger enforces 24h window + sender-only rule.
|
||||||
// enforces the 24h window + sender-only rule.
|
export async function editEncryptedMessage(
|
||||||
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
|
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
||||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
): Promise<void> {
|
||||||
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 plainBytes = utf8ToBytes(params.newPlaintext);
|
const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key);
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
|
const { error } = await params.client
|
||||||
const { error: mErr } = await params.client
|
|
||||||
.from('messages')
|
.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);
|
.eq('id', params.messageId);
|
||||||
if (mErr) throw mErr;
|
if (error) throw error;
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteMessage(
|
export async function softDeleteMessage(
|
||||||
@@ -365,24 +311,46 @@ export async function removeReaction(
|
|||||||
// Decrypt helpers
|
// Decrypt helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface DecryptOptions {
|
export interface DecryptParams {
|
||||||
|
client: AppSupabaseClient;
|
||||||
|
messages: MessageWithCipher[];
|
||||||
|
ownDeviceId: string;
|
||||||
ownPrivateKey: Uint8Array;
|
ownPrivateKey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptMessages(opts: {
|
// Decrypts messages using their conv-key (looked up + cached per
|
||||||
messages: ChatMessage[];
|
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
||||||
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
|
// for that version yet (e.g. brand-new device waiting for share).
|
||||||
senderKeys: Map<string, Uint8Array>;
|
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
||||||
ownPrivateKey: Uint8Array;
|
|
||||||
}): Promise<DecryptedMessage[]> {
|
|
||||||
const out: 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) {
|
for (const m of opts.messages) {
|
||||||
const env = opts.envelopes.get(m.id);
|
let convCache = versions.get(m.conversationId);
|
||||||
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
|
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;
|
let plaintext: string | null = null;
|
||||||
if (env && senderKey) {
|
if (key) {
|
||||||
try {
|
try {
|
||||||
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
|
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
|
||||||
plaintext = bytesToUtf8(decoded);
|
plaintext = bytesToUtf8(decoded);
|
||||||
} catch {
|
} catch {
|
||||||
plaintext = null;
|
plaintext = null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './backend.js';
|
export * from './backend.js';
|
||||||
export * from './box.js';
|
export * from './box.js';
|
||||||
export * from './keys.js';
|
export * from './keys.js';
|
||||||
|
export * from './sessionKeys.js';
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { getCryptoBackend } from './backend.js';
|
||||||
|
import { decryptFrom, encryptFor, type EncryptedEnvelope } from './box.js';
|
||||||
|
|
||||||
|
// Sender-Key (Signal-style) helpers — one symmetric XSalsa20-Poly1305 key per
|
||||||
|
// conversation, wrapped with `crypto_box` for each recipient device's pubkey.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// - generateConvKey() produces 32 random bytes
|
||||||
|
// - wrapConvKeyForRecipient() encrypts the conv-key with sender's private key
|
||||||
|
// and recipient's pubkey -> stored in `conversation_keys` table
|
||||||
|
// - unwrapConvKey() reverses it on the receiving side
|
||||||
|
// - encryptWithConvKey()/decryptWithConvKey() do the message-payload work
|
||||||
|
|
||||||
|
export function generateConvKey(): Uint8Array {
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
return backend.randomBytes(backend.secretboxKeyLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrapConvKeyForRecipient(
|
||||||
|
convKey: Uint8Array,
|
||||||
|
recipientPublicKey: Uint8Array,
|
||||||
|
senderPrivateKey: Uint8Array,
|
||||||
|
): Promise<EncryptedEnvelope> {
|
||||||
|
return encryptFor(convKey, recipientPublicKey, senderPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwrapConvKey(
|
||||||
|
encryptedKey: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
senderPublicKey: Uint8Array,
|
||||||
|
recipientPrivateKey: Uint8Array,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
return decryptFrom(encryptedKey, nonce, senderPublicKey, recipientPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConvCipher {
|
||||||
|
ciphertext: Uint8Array;
|
||||||
|
nonce: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptWithConvKey(
|
||||||
|
plaintext: Uint8Array,
|
||||||
|
convKey: Uint8Array,
|
||||||
|
): ConvCipher {
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||||
|
const ciphertext = backend.secretbox(plaintext, nonce, convKey);
|
||||||
|
return { ciphertext, nonce };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptWithConvKey(
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
convKey: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
return backend.secretboxOpen(ciphertext, nonce, convKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- Sender-Key (Signal-style) multi-device crypto.
|
||||||
|
--
|
||||||
|
-- Replaces per-device envelopes with a per-conversation symmetric key.
|
||||||
|
-- Each device that should be able to read a conversation gets one row in
|
||||||
|
-- `conversation_keys` containing the conv-key wrapped to its X25519 pubkey.
|
||||||
|
--
|
||||||
|
-- Senders encrypt the message body once with the conv-key (XSalsa20-Poly1305
|
||||||
|
-- secretbox) instead of N times to N device pubkeys. This makes new-device
|
||||||
|
-- onboarding for existing conversations possible: any existing device can
|
||||||
|
-- wrap the conv-key for a freshly-registered device, and that device can then
|
||||||
|
-- decrypt the entire history without back-population.
|
||||||
|
--
|
||||||
|
-- Pre-launch wipe: existing messages + envelopes are dropped (the old
|
||||||
|
-- per-device envelopes can't be re-encoded into the new format without the
|
||||||
|
-- sender's private key, which lives only on each user's device).
|
||||||
|
|
||||||
|
-- 1) Wipe legacy message data --------------------------------------------------
|
||||||
|
|
||||||
|
drop table if exists public.message_envelopes cascade;
|
||||||
|
truncate table
|
||||||
|
public.message_reactions,
|
||||||
|
public.message_reads,
|
||||||
|
public.message_attachments,
|
||||||
|
public.messages
|
||||||
|
restart identity cascade;
|
||||||
|
|
||||||
|
-- 2) Add ciphertext + nonce + key_version columns to messages -----------------
|
||||||
|
|
||||||
|
alter table public.messages
|
||||||
|
add column if not exists ciphertext bytea,
|
||||||
|
add column if not exists nonce bytea,
|
||||||
|
add column if not exists key_version int;
|
||||||
|
|
||||||
|
-- Backfill not needed because we just truncated. Make NOT NULL going forward.
|
||||||
|
alter table public.messages
|
||||||
|
alter column ciphertext set not null,
|
||||||
|
alter column nonce set not null,
|
||||||
|
alter column key_version set not null,
|
||||||
|
alter column key_version set default 1;
|
||||||
|
|
||||||
|
-- 3) Track currently-active key version per conversation ----------------------
|
||||||
|
|
||||||
|
alter table public.conversations
|
||||||
|
add column if not exists active_key_version int not null default 1;
|
||||||
|
|
||||||
|
-- 4) conversation_keys table ---------------------------------------------------
|
||||||
|
--
|
||||||
|
-- One row per (conversation, recipient_device, key_version). The wrapped key
|
||||||
|
-- is encrypted with `crypto_box`/`box`-style asymmetric crypto (X25519 +
|
||||||
|
-- XSalsa20-Poly1305) — sender_device's private key + recipient_device's
|
||||||
|
-- public key derive a shared secret to decrypt the embedded conv-key.
|
||||||
|
|
||||||
|
create table if not exists public.conversation_keys (
|
||||||
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||||
|
recipient_device_id uuid not null references public.devices(id) on delete cascade,
|
||||||
|
key_version int not null,
|
||||||
|
sender_device_id uuid not null references public.devices(id) on delete restrict,
|
||||||
|
encrypted_key bytea not null,
|
||||||
|
nonce bytea not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (conversation_id, recipient_device_id, key_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists conversation_keys_recipient_idx
|
||||||
|
on public.conversation_keys(recipient_device_id);
|
||||||
|
|
||||||
|
create index if not exists conversation_keys_conv_version_idx
|
||||||
|
on public.conversation_keys(conversation_id, key_version);
|
||||||
|
|
||||||
|
-- 5) RLS policies for conversation_keys ---------------------------------------
|
||||||
|
|
||||||
|
alter table public.conversation_keys enable row level security;
|
||||||
|
|
||||||
|
-- A user can SELECT a row only if the recipient device belongs to them.
|
||||||
|
drop policy if exists conversation_keys_select_owner on public.conversation_keys;
|
||||||
|
create policy conversation_keys_select_owner
|
||||||
|
on public.conversation_keys
|
||||||
|
for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.devices d
|
||||||
|
where d.id = recipient_device_id
|
||||||
|
and d.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- INSERT allowed only by an authenticated user who:
|
||||||
|
-- (a) is a member of the conversation,
|
||||||
|
-- (b) owns the sender_device_id (so a 3rd party can't impersonate),
|
||||||
|
-- (c) the recipient_device belongs to a member of the same conversation.
|
||||||
|
drop policy if exists conversation_keys_insert_member on public.conversation_keys;
|
||||||
|
create policy conversation_keys_insert_member
|
||||||
|
on public.conversation_keys
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.conversation_members m
|
||||||
|
where m.conversation_id = conversation_keys.conversation_id
|
||||||
|
and m.user_id = auth.uid()
|
||||||
|
and m.accepted = true
|
||||||
|
)
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from public.devices d
|
||||||
|
where d.id = sender_device_id
|
||||||
|
and d.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
and exists (
|
||||||
|
select 1
|
||||||
|
from public.devices d
|
||||||
|
join public.conversation_members m
|
||||||
|
on m.user_id = d.user_id
|
||||||
|
and m.conversation_id = conversation_keys.conversation_id
|
||||||
|
where d.id = recipient_device_id
|
||||||
|
and m.accepted = true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- DELETE: only sender (cleanup, e.g. when a device is removed). Rare.
|
||||||
|
drop policy if exists conversation_keys_delete_sender on public.conversation_keys;
|
||||||
|
create policy conversation_keys_delete_sender
|
||||||
|
on public.conversation_keys
|
||||||
|
for delete
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.devices d
|
||||||
|
where d.id = sender_device_id
|
||||||
|
and d.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6) Realtime publication ------------------------------------------------------
|
||||||
|
--
|
||||||
|
-- Devices subscribe to INSERTs on `conversation_keys` so a freshly-registered
|
||||||
|
-- device sees its key bundles arrive. They also already subscribe to `devices`
|
||||||
|
-- INSERTs to know when peers join a conversation; that's set up in earlier
|
||||||
|
-- migrations.
|
||||||
|
|
||||||
|
alter publication supabase_realtime add table public.conversation_keys;
|
||||||
Reference in New Issue
Block a user