refactor(shared): conv-keys target user-id instead of device-id
This commit is contained in:
@@ -1,35 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
decryptWithConvKey,
|
decryptWithConvKey,
|
||||||
encryptWithConvKey,
|
encryptWithConvKey,
|
||||||
generateConvKey,
|
generateConvKey,
|
||||||
unwrapConvKey,
|
unwrapConvKey,
|
||||||
wrapConvKeyForRecipient,
|
wrapConvKeyForRecipient,
|
||||||
} from '../crypto/sessionKeys';
|
} from '../crypto/sessionKeys';
|
||||||
|
import { fetchPeerPublicKeys } from '../auth/userKey';
|
||||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
|
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
|
||||||
import type { AppSupabaseClient } from '../supabase/client';
|
import type { AppSupabaseClient } from '../supabase/client';
|
||||||
|
|
||||||
// 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) {
|
function rawFrom(client: AppSupabaseClient, table: string) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return (client as unknown as { from: (t: string) => any }).from(table);
|
return (client as unknown as { from: (t: string) => any }).from(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-conversation symmetric key management. Replaces per-device envelopes
|
export interface OwnUserCtx {
|
||||||
// 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;
|
userId: string;
|
||||||
publicKey: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OwnDeviceCtx {
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
privateKey: Uint8Array;
|
privateKey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,37 +25,22 @@ export interface ConvKeyHandle {
|
|||||||
key: Uint8Array;
|
key: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt.
|
|
||||||
const cache = new Map<string, ConvKeyHandle>();
|
const cache = new Map<string, ConvKeyHandle>();
|
||||||
const cacheKey = (convId: string, version: number) => convId + '@' + version;
|
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
||||||
|
|
||||||
export function clearConvKeyCache(): void {
|
export function clearConvKeyCache(): void { cache.clear(); }
|
||||||
cache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listDeviceKeys(
|
async function listMemberPublicKeys(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<DeviceKey[]> {
|
): Promise<{ userId: string; publicKey: Uint8Array }[]> {
|
||||||
const { data: members, error: mErr } = await client
|
const { data: members, error } = await client
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('user_id, accepted')
|
.select('user_id, accepted')
|
||||||
.eq('conversation_id', conversationId);
|
.eq('conversation_id', conversationId);
|
||||||
if (mErr) throw mErr;
|
if (error) throw error;
|
||||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
const ids = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||||
if (memberIds.length === 0) return [];
|
return fetchPeerPublicKeys(client, ids);
|
||||||
|
|
||||||
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(
|
async function fetchActiveKeyVersion(
|
||||||
@@ -77,212 +48,147 @@ async function fetchActiveKeyVersion(
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { data, error } = await rawFrom(client, 'conversations')
|
const { data, error } = await rawFrom(client, 'conversations')
|
||||||
.select('active_key_version')
|
.select('active_key_version').eq('id', conversationId).single();
|
||||||
.eq('id', conversationId)
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (data as { active_key_version: number }).active_key_version;
|
return (data as { active_key_version: number }).active_key_version;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SenderInfo {
|
interface SenderInfo { senderUserId: string; senderPublicKey: Uint8Array }
|
||||||
senderDeviceId: string;
|
|
||||||
senderPublicKey: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchKeyBundle(
|
async function fetchKeyBundle(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
ownDeviceId: string,
|
ownUserId: string,
|
||||||
keyVersion: number,
|
keyVersion: number,
|
||||||
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
|
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
|
||||||
const { data, error } = await rawFrom(client, 'conversation_keys')
|
const { data, error } = await rawFrom(client, 'conversation_keys')
|
||||||
.select('encrypted_key, nonce, sender_device_id')
|
.select('encrypted_key, nonce, sender_user_id')
|
||||||
.eq('conversation_id', conversationId)
|
.eq('conversation_id', conversationId)
|
||||||
.eq('recipient_device_id', ownDeviceId)
|
.eq('recipient_user_id', ownUserId)
|
||||||
.eq('key_version', keyVersion)
|
.eq('key_version', keyVersion)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
const row = data as { encrypted_key: string; nonce: string; sender_user_id: string };
|
||||||
const row = data as {
|
const peers = await fetchPeerPublicKeys(client, [row.sender_user_id]);
|
||||||
encrypted_key: string;
|
const sender = peers[0];
|
||||||
nonce: string;
|
if (!sender) throw new Error('sender public key missing');
|
||||||
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 {
|
return {
|
||||||
encryptedKey: pgHexToBytes(row.encrypted_key),
|
encryptedKey: pgHexToBytes(row.encrypted_key),
|
||||||
nonce: pgHexToBytes(row.nonce),
|
nonce: pgHexToBytes(row.nonce),
|
||||||
sender: {
|
sender: { senderUserId: sender.userId, senderPublicKey: sender.publicKey },
|
||||||
senderDeviceId: dev.id,
|
|
||||||
senderPublicKey: pgHexToBytes(dev.public_key),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strips the leading `\x` postgres bytea hex prefix so the RPC's
|
function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); }
|
||||||
// `decode(text, 'hex')` accepts it.
|
|
||||||
function hexNoPrefix(bytes: Uint8Array): string {
|
|
||||||
return bytesToPgHex(bytes).slice(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. All inserts go
|
|
||||||
// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid
|
|
||||||
// recipients, no per-row 403 console spam.
|
|
||||||
export async function bootstrapConvKey(
|
export async function bootstrapConvKey(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
own: OwnDeviceCtx,
|
own: OwnUserCtx,
|
||||||
keyVersion: number,
|
keyVersion: number,
|
||||||
): Promise<ConvKeyHandle> {
|
): Promise<ConvKeyHandle> {
|
||||||
const convKey = generateConvKey();
|
const convKey = generateConvKey();
|
||||||
const recipients = await listDeviceKeys(client, conversationId);
|
const recipients = await listMemberPublicKeys(client, conversationId);
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) throw new Error('cannot bootstrap conv key — no recipients');
|
||||||
throw new Error('cannot bootstrap conv key — no recipient devices');
|
const bundles: Array<{ recipient_user_id: string; encrypted_key: string; nonce: string }> = [];
|
||||||
}
|
|
||||||
|
|
||||||
const bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = [];
|
|
||||||
for (const r of recipients) {
|
for (const r of recipients) {
|
||||||
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
||||||
bundles.push({
|
bundles.push({
|
||||||
recipient_device_id: r.deviceId,
|
recipient_user_id: r.userId,
|
||||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||||
nonce: hexNoPrefix(wrapped.nonce),
|
nonce: hexNoPrefix(wrapped.nonce),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||||||
p_conv_id: conversationId,
|
p_conv_id: conversationId,
|
||||||
p_sender_device_id: own.deviceId,
|
p_sender_device_id: null,
|
||||||
|
p_sender_user_id: own.userId,
|
||||||
p_key_version: keyVersion,
|
p_key_version: keyVersion,
|
||||||
p_bundles: bundles,
|
p_bundles: bundles,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
const handle = { conversationId, keyVersion, key: convKey };
|
const handle = { conversationId, keyVersion, key: convKey };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return 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(
|
export async function getOrCreateConvKey(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
own: OwnDeviceCtx,
|
own: OwnUserCtx,
|
||||||
): Promise<ConvKeyHandle> {
|
): Promise<ConvKeyHandle> {
|
||||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||||
const cached = cache.get(cacheKey(conversationId, version));
|
const cached = cache.get(cacheKey(conversationId, version));
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version);
|
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
const key = await unwrapConvKey(
|
const key = await unwrapConvKey(
|
||||||
bundle.encryptedKey,
|
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
||||||
bundle.nonce,
|
|
||||||
bundle.sender.senderPublicKey,
|
|
||||||
own.privateKey,
|
|
||||||
);
|
);
|
||||||
const handle = { conversationId, keyVersion: version, key };
|
const handle = { conversationId, keyVersion: version, key };
|
||||||
cache.set(cacheKey(conversationId, version), handle);
|
cache.set(cacheKey(conversationId, version), handle);
|
||||||
return 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')
|
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||||
.select('recipient_device_id', { count: 'exact', head: true })
|
.select('recipient_user_id', { count: 'exact', head: true })
|
||||||
.eq('conversation_id', conversationId)
|
.eq('conversation_id', conversationId)
|
||||||
.eq('key_version', version);
|
.eq('key_version', version);
|
||||||
if (cntErr) throw cntErr;
|
if (cntErr) throw cntErr;
|
||||||
|
|
||||||
if ((count ?? 0) > 0) {
|
if ((count ?? 0) > 0) {
|
||||||
throw new Error(
|
throw new Error('Awaiting conversation key — another user must share it with this user.');
|
||||||
'Awaiting conversation key — another device must share it with this device.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return bootstrapConvKey(client, conversationId, own, version);
|
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(
|
export async function tryGetConvKey(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
ownDeviceId: string,
|
ownUserId: string,
|
||||||
ownPrivateKey: Uint8Array,
|
ownPrivateKey: Uint8Array,
|
||||||
keyVersion: number,
|
keyVersion: number,
|
||||||
): Promise<ConvKeyHandle | null> {
|
): Promise<ConvKeyHandle | null> {
|
||||||
const cached = cache.get(cacheKey(conversationId, keyVersion));
|
const cached = cache.get(cacheKey(conversationId, keyVersion));
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion);
|
|
||||||
if (!bundle) return null;
|
if (!bundle) return null;
|
||||||
const key = await unwrapConvKey(
|
const key = await unwrapConvKey(
|
||||||
bundle.encryptedKey,
|
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||||||
bundle.nonce,
|
|
||||||
bundle.sender.senderPublicKey,
|
|
||||||
ownPrivateKey,
|
|
||||||
);
|
);
|
||||||
const handle = { conversationId, keyVersion, key };
|
const handle = { conversationId, keyVersion, key };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps the active conv-key for a single new device (e.g. when a peer
|
export async function shareConvKeyToUser(
|
||||||
// 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,
|
client: AppSupabaseClient,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
recipientDeviceId: string,
|
recipientUserId: string,
|
||||||
recipientPublicKey: Uint8Array,
|
recipientPublicKey: Uint8Array,
|
||||||
own: OwnDeviceCtx,
|
own: OwnUserCtx,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||||
const handle =
|
const handle =
|
||||||
cache.get(cacheKey(conversationId, version)) ??
|
cache.get(cacheKey(conversationId, version)) ??
|
||||||
(await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version));
|
(await tryGetConvKey(client, conversationId, own.userId, own.privateKey, version));
|
||||||
if (!handle) {
|
if (!handle) throw new Error('cannot share conv key — own user does not have it yet');
|
||||||
throw new Error('cannot share conv key — own device does not have it yet');
|
const wrapped = await wrapConvKeyForRecipient(handle.key, recipientPublicKey, own.privateKey);
|
||||||
}
|
|
||||||
|
|
||||||
const wrapped = await wrapConvKeyForRecipient(
|
|
||||||
handle.key,
|
|
||||||
recipientPublicKey,
|
|
||||||
own.privateKey,
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||||||
p_conv_id: conversationId,
|
p_conv_id: conversationId,
|
||||||
p_sender_device_id: own.deviceId,
|
p_sender_device_id: null,
|
||||||
|
p_sender_user_id: own.userId,
|
||||||
p_key_version: version,
|
p_key_version: version,
|
||||||
p_bundles: [
|
p_bundles: [{
|
||||||
{
|
recipient_user_id: recipientUserId,
|
||||||
recipient_device_id: recipientDeviceId,
|
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
nonce: hexNoPrefix(wrapped.nonce),
|
||||||
nonce: hexNoPrefix(wrapped.nonce),
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-exports for convenience.
|
|
||||||
export { decryptWithConvKey, encryptWithConvKey };
|
export { decryptWithConvKey, encryptWithConvKey };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
decryptWithConvKey,
|
decryptWithConvKey,
|
||||||
encryptWithConvKey,
|
encryptWithConvKey,
|
||||||
getOrCreateConvKey,
|
getOrCreateConvKey,
|
||||||
type OwnDeviceCtx,
|
type OwnUserCtx,
|
||||||
tryGetConvKey,
|
tryGetConvKey,
|
||||||
} from './convKeys';
|
} from './convKeys';
|
||||||
import type { ChatMessage, DecryptedMessage } from './types';
|
import type { ChatMessage, DecryptedMessage } from './types';
|
||||||
@@ -104,9 +104,8 @@ export interface SendMessageParams {
|
|||||||
// send and shared with every existing recipient device. New devices that
|
// send and shared with every existing recipient device. New devices that
|
||||||
// register later receive their key bundle through `shareConvKeyToDevice`.
|
// 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 ownCtx: OwnDeviceCtx = {
|
const ownCtx: OwnUserCtx = {
|
||||||
userId: params.senderUserId,
|
userId: params.senderUserId,
|
||||||
deviceId: params.senderDeviceId,
|
|
||||||
privateKey: params.senderPrivateKey,
|
privateKey: params.senderPrivateKey,
|
||||||
};
|
};
|
||||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||||
@@ -175,9 +174,8 @@ export interface EditMessageParams {
|
|||||||
export async function editEncryptedMessage(
|
export async function editEncryptedMessage(
|
||||||
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ownCtx: OwnDeviceCtx = {
|
const ownCtx: OwnUserCtx = {
|
||||||
userId: params.senderUserId,
|
userId: params.senderUserId,
|
||||||
deviceId: params.senderDeviceId,
|
|
||||||
privateKey: params.senderPrivateKey,
|
privateKey: params.senderPrivateKey,
|
||||||
};
|
};
|
||||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||||
|
|||||||
Reference in New Issue
Block a user