Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 588b843904 | |||
| dbf8030e93 | |||
| 615770722e | |||
| f1cba99b9e | |||
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.21.0",
|
||||
"version": "0.21.4",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
Binary file not shown.
@@ -1,16 +1,16 @@
|
||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
clearConvKeyCache,
|
||||
type DecryptedMessage,
|
||||
decryptMessages,
|
||||
encryptAndUploadAttachment,
|
||||
fetchConversationMessages,
|
||||
getOrCreateConvKey,
|
||||
insertAttachmentRow,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
rotateConvKey,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -125,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
||||
// for them. Members who are missing one (typically peers who haven't yet
|
||||
// migrated to the per-user key model) get a best-effort wrap from the
|
||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
||||
// read on first unlock without manual intervention from A.
|
||||
// Proactive rewrap sweep: when a conversation opens, ensure the active
|
||||
// conv-key has a `recipient_user_id` bundle for every accepted member.
|
||||
//
|
||||
// If any peer is missing a bundle at the active version, the previous
|
||||
// implementation called `shareConvKeyToUser` for each missing peer —
|
||||
// that helper reads from the module-level conv-key cache first, and if
|
||||
// the cache held a STALE locally-generated key (from a buggy bootstrap
|
||||
// race in an earlier app version), the stale key got propagated to the
|
||||
// peer's row. Both sides then encrypt with mutually un-mergeable keys
|
||||
// and every message is "Nachricht nicht lesbar" forever (incident:
|
||||
// conv aae12d84).
|
||||
//
|
||||
// The replacement: when any peer is missing, call `rotateConvKey` once.
|
||||
// Rotation generates a fresh symmetric key locally, fetches each member's
|
||||
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
|
||||
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
|
||||
// serialises concurrent rotations). This bypasses the cache entirely:
|
||||
// the new version's cache entry is the just-rotated key, and the stale
|
||||
// entry at the old version is irrelevant because nobody reads it any more.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId) return;
|
||||
let cancelled = false;
|
||||
@@ -154,56 +167,76 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
// First, make sure we have a usable handle for the active version
|
||||
// (this auto-rotates if we're locked out of our own bundle — the
|
||||
// recovery path added in v0.21.1/v0.21.2).
|
||||
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||
userId,
|
||||
privateKey: priv,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (handle.keyVersion > version) return; // already rotated by helper
|
||||
|
||||
// Check membership state on the server.
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr || !members) return;
|
||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||
.filter((m) => m.accepted && m.user_id !== userId)
|
||||
.map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return;
|
||||
if (peerIds.length === 0) return;
|
||||
|
||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||||
for (const peer of peers) {
|
||||
if (cancelled) return;
|
||||
const { count, error: cntErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (s: string, o?: object) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (
|
||||
...a: unknown[]
|
||||
) => Promise<{ count: number | null; error: unknown }>;
|
||||
};
|
||||
// Count how many of the peers have a recipient_user_id bundle at
|
||||
// the active version. If any are missing, rotate to V+1 — the
|
||||
// rotation will wrap a fresh key for every accepted member with a
|
||||
// user_keys row.
|
||||
const { data: existingRows, error: rowsErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (s: string) => {
|
||||
eq: (c: string, v: string) => {
|
||||
eq: (c: string, v: number) => {
|
||||
in: (c: string, v: string[]) => Promise<{
|
||||
data: Array<{ recipient_user_id: string }> | null;
|
||||
error: unknown;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_user_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_user_id', peer.userId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) continue;
|
||||
if ((count ?? 0) === 0) {
|
||||
try {
|
||||
await shareConvKeyToUser(
|
||||
supabase,
|
||||
conversationId,
|
||||
peer.userId,
|
||||
peer.publicKey,
|
||||
{ userId, privateKey: priv },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap failed for', peer.userId, err);
|
||||
}
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', version)
|
||||
.in('recipient_user_id', peerIds);
|
||||
if (rowsErr) return;
|
||||
const wrappedPeerIds = new Set(
|
||||
(existingRows ?? []).map((r) => r.recipient_user_id),
|
||||
);
|
||||
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
|
||||
if (missing.length === 0) return;
|
||||
|
||||
// At least one peer is missing a bundle — rotate. We deliberately do
|
||||
// NOT use the cached conv-key here. The rotation generates a fresh
|
||||
// key wrapped to every current member's CURRENT pubkey, so any
|
||||
// staleness in the local cache for the OLD version is irrelevant
|
||||
// going forward.
|
||||
try {
|
||||
await rotateConvKey(supabase, conversationId, {
|
||||
userId,
|
||||
privateKey: priv,
|
||||
});
|
||||
} catch (err) {
|
||||
// Most likely cause: a concurrent peer also called rotate and
|
||||
// won the race; their bumped active_key_version makes our
|
||||
// `p_new_version <= cur_version` and the RPC raises. That's fine —
|
||||
// the next chat-open / send will fetch the new active version and
|
||||
// unwrap the bundle that peer wrapped for us.
|
||||
console.warn('proactive rotate failed (likely concurrent rotation)', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap sweep failed', err);
|
||||
@@ -474,11 +507,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
void refresh();
|
||||
|
||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
||||
// the last 100 in one query — cheaper and keeps order stable. For
|
||||
// lone inserts the per-id path stays so latency is unchanged.
|
||||
const BATCH_WINDOW_MS = 250;
|
||||
// refetches + decrypts. The first event in a quiet period fires
|
||||
// `handleInsert` immediately so single incoming messages don't sit
|
||||
// behind a debounce timer (previous behaviour: 250 ms blank between
|
||||
// notification-sound and message body). Subsequent events arriving
|
||||
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
|
||||
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
|
||||
// `refresh()` instead of N individual refetches.
|
||||
const BATCH_WINDOW_MS = 80;
|
||||
const BATCH_BURST_THRESHOLD = 3;
|
||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||
let burstTimer: number | null = null;
|
||||
@@ -497,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
};
|
||||
const queueInsert = (row: Record<string, unknown>) => {
|
||||
if (burstBuffer.length === 0 && burstTimer === null) {
|
||||
// First event in a quiet period — fire immediately so the user sees
|
||||
// the message right when they hear the notification sound. Arm a
|
||||
// short window in case a burst follows; follow-ups go through the
|
||||
// buffer and may collapse into a refresh.
|
||||
void handleInsert(row);
|
||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||
return;
|
||||
}
|
||||
burstBuffer.push(row);
|
||||
if (burstTimer === null) {
|
||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||
@@ -523,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
},
|
||||
)
|
||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
||||
// registered a fresh device), re-decrypt the visible messages.
|
||||
// Any conversation_keys change for this conv invalidates the cached
|
||||
// conv-key for the affected version. The module-level cache in
|
||||
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
|
||||
// forever within a session — which is exactly what propagated the
|
||||
// stale local bootstrap key in conv aae12d84, recreating divergent
|
||||
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
|
||||
// DELETE for the conv forces the next `getOrCreateConvKey` /
|
||||
// `tryGetConvKey` call to re-fetch the canonical bundle from the
|
||||
// server. Cheap (a single Map.delete), defensive, and avoids stale-
|
||||
// cache propagation across all of {peer rotation, device wrap, admin
|
||||
// cleanup}.
|
||||
//
|
||||
// We also keep the historical "device wrap → refresh" trigger so a
|
||||
// freshly-registered device of our own re-decrypts in place.
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'conversation_keys',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
(payload: { new: { recipient_device_id?: string } }) => {
|
||||
if (payload.new?.recipient_device_id === deviceId) {
|
||||
(payload: {
|
||||
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: { recipient_device_id?: string; key_version?: number };
|
||||
old: { recipient_device_id?: string; key_version?: number };
|
||||
}) => {
|
||||
const v =
|
||||
payload.eventType === 'DELETE'
|
||||
? payload.old?.key_version
|
||||
: payload.new?.key_version;
|
||||
if (typeof v === 'number') {
|
||||
clearConvKeyCache(conversationId, v);
|
||||
} else {
|
||||
clearConvKeyCache(conversationId);
|
||||
}
|
||||
if (
|
||||
payload.eventType === 'INSERT' &&
|
||||
payload.new?.recipient_device_id === deviceId
|
||||
) {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -754,6 +754,24 @@ export function ConversationPage() {
|
||||
lastPendingCountRef.current = pending.length;
|
||||
}, [pending.length]);
|
||||
|
||||
// Snap the viewport back to the bottom after a send. The composer
|
||||
// shrinks (cleared text, dismissed reply preview, dropped attachment
|
||||
// thumbs) which lets the Virtuoso area grow vertically — leaving the
|
||||
// just-sent bubble visibly above the new bottom for a frame.
|
||||
// `requestAnimationFrame` defers the scroll until React has committed
|
||||
// the composer-height change, so Virtuoso's ResizeObserver has
|
||||
// already seen the new viewport and `index: 'LAST', align: 'end'`
|
||||
// targets the correct bottom edge.
|
||||
const snapToBottom = useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: 'LAST',
|
||||
align: 'end',
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleSend(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||
@@ -773,6 +791,7 @@ export function ConversationPage() {
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
if (id) clearDraft(id);
|
||||
snapToBottom();
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
setSendError(
|
||||
@@ -798,13 +817,14 @@ export function ConversationPage() {
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
snapToBottom();
|
||||
} catch (err: unknown) {
|
||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||
} finally {
|
||||
setPollSending(false);
|
||||
}
|
||||
},
|
||||
[send, replyTo?.id, notifyStopTyping],
|
||||
[send, replyTo?.id, notifyStopTyping, snapToBottom],
|
||||
);
|
||||
|
||||
const handleCreateWhiteboard = useCallback(async () => {
|
||||
@@ -817,12 +837,13 @@ export function ConversationPage() {
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
setOpenWhiteboardId(board.id);
|
||||
snapToBottom();
|
||||
} catch (err: unknown) {
|
||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||
} finally {
|
||||
setCreatingWhiteboard(false);
|
||||
}
|
||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
||||
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
|
||||
|
||||
const handleStartWatchTogether = useCallback(async () => {
|
||||
if (!id) return;
|
||||
@@ -842,12 +863,13 @@ export function ConversationPage() {
|
||||
setWatchDialogOpen(false);
|
||||
setWatchUrl('');
|
||||
setOpenWatchSessionId(ws.id);
|
||||
snapToBottom();
|
||||
} catch (err: unknown) {
|
||||
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||
} finally {
|
||||
setWatchCreating(false);
|
||||
}
|
||||
}, [id, watchUrl, send, replyTo?.id]);
|
||||
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
|
||||
|
||||
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||
if (!id) return;
|
||||
@@ -874,12 +896,13 @@ export function ConversationPage() {
|
||||
setStickToBottom(true);
|
||||
setGameDialogOpen(false);
|
||||
setOpenGameId(game.id);
|
||||
snapToBottom();
|
||||
} catch (err: unknown) {
|
||||
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||
} finally {
|
||||
setGameCreating(false);
|
||||
}
|
||||
}, [id, conversation, myId, send, replyTo?.id]);
|
||||
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
const compressed = await compressImages(files);
|
||||
@@ -1046,14 +1069,32 @@ export function ConversationPage() {
|
||||
// the bottom; returning `false` from the callback when they're
|
||||
// scrolled up preserves their reading position when realtime
|
||||
// messages arrive (critical UX: do NOT jerk the user).
|
||||
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
|
||||
//
|
||||
// We deliberately use 'auto' (instant) rather than 'smooth':
|
||||
// with a smooth scroll animation, atBottomStateChange fires
|
||||
// `false` mid-animation (scrollTop is briefly above the new
|
||||
// bottom) and then `true` after settle — that flips
|
||||
// stickToBottom twice, flashing the "Zum neuesten" pill and
|
||||
// re-rendering the whole list. Instant scroll has zero
|
||||
// mid-animation state so the cascade never happens.
|
||||
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
atBottomThreshold={80}
|
||||
// 250 px tolerance — large enough that appending a tall row
|
||||
// (image, voice note, grouped attachments) doesn't push the
|
||||
// user out of the at-bottom zone. The previous 80 px flipped
|
||||
// stickToBottom on nearly every typical message arrival.
|
||||
atBottomThreshold={250}
|
||||
rangeChanged={handleRangeChanged}
|
||||
startReached={handleStartReached}
|
||||
// Render rows just outside the viewport so fast scrolling
|
||||
// doesn't briefly flash empty space.
|
||||
increaseViewportBy={400}
|
||||
// Visual breathing space below the last message so a bubble
|
||||
// bottom doesn't sit flush against the composer top — matches
|
||||
// Discord's chat-pane bottom padding.
|
||||
components={{
|
||||
Footer: () => <div style={{ height: '12px' }} />,
|
||||
}}
|
||||
itemContent={(_index, row) => {
|
||||
if (row.kind === 'loader') {
|
||||
return (
|
||||
|
||||
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
|
||||
const cache = new Map<string, ConvKeyHandle>();
|
||||
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
||||
|
||||
export function clearConvKeyCache(): void { cache.clear(); }
|
||||
// Clear the in-memory conv-key cache. Three modes:
|
||||
// * no args → clear everything (e.g. on logout)
|
||||
// * convId only → clear all key-version entries for this conversation
|
||||
// * convId + v → clear just the specific (conv, version) entry
|
||||
//
|
||||
// Callers that observe a peer rotation or a server-side conv-keys mutation
|
||||
// MUST invalidate the affected entries so subsequent `getOrCreateConvKey` /
|
||||
// `tryGetConvKey` calls re-fetch the canonical bundle from the server
|
||||
// instead of returning a now-stale cached key.
|
||||
export function clearConvKeyCache(conversationId?: string, keyVersion?: number): void {
|
||||
if (conversationId === undefined) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
if (keyVersion !== undefined) {
|
||||
cache.delete(cacheKey(conversationId, keyVersion));
|
||||
return;
|
||||
}
|
||||
for (const key of Array.from(cache.keys())) {
|
||||
if (key.startsWith(conversationId + '@')) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function listMemberPublicKeys(
|
||||
client: AppSupabaseClient,
|
||||
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
|
||||
p_bundles: bundles,
|
||||
});
|
||||
if (error) throw error;
|
||||
const handle = { conversationId, keyVersion, key: convKey };
|
||||
|
||||
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
|
||||
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
|
||||
// OUR INSERTs were silently skipped server-side and the row on the server
|
||||
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
|
||||
// would leave both clients with mutually un-decryptable bundles (each
|
||||
// encrypting/decrypting with its own key — exactly the bug that broke
|
||||
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
|
||||
// server key. Whoever wrote first wins; the loser converges.
|
||||
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
|
||||
if (!ownBundle) {
|
||||
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
|
||||
}
|
||||
const canonicalKey = await unwrapConvKey(
|
||||
ownBundle.encryptedKey,
|
||||
ownBundle.nonce,
|
||||
ownBundle.sender.senderPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
const handle = { conversationId, keyVersion, key: canonicalKey };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
}
|
||||
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
|
||||
if (cached) return cached;
|
||||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, 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;
|
||||
try {
|
||||
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;
|
||||
} catch (err) {
|
||||
// A bundle exists for us but our current private key cannot unwrap it.
|
||||
// The most common cause is `reset_user_key`: a fresh user-key pair was
|
||||
// generated locally while the on-server bundle is still wrapped against
|
||||
// the previous public key. Treat this the same as "no bundle for me" —
|
||||
// mint a fresh conv-key at version+1 wrapped to our CURRENT key. Old
|
||||
// messages stay unreadable for us; new ones flow.
|
||||
console.warn(
|
||||
'[conv-key] unwrap own bundle failed at v' + version + ' — auto-rotating',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||
.select('recipient_user_id', { count: 'exact', head: true })
|
||||
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
|
||||
.eq('key_version', version);
|
||||
if (cntErr) throw cntErr;
|
||||
if ((count ?? 0) > 0) {
|
||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
||||
// that originally received my bundle, or my own bundle was wiped by the
|
||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
||||
// unreadable for me; new ones flow.
|
||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
||||
// Rows exist for this version, but none usable for me. Either I lost the
|
||||
// device-key that originally received my bundle, my own bundle was wiped
|
||||
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
|
||||
// bundle is unwrappable (handled in the try/catch above). The only way
|
||||
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
|
||||
// we can. Old messages stay unreadable for me; new ones flow.
|
||||
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
|
||||
return rotateConvKey(client, conversationId, own);
|
||||
}
|
||||
return bootstrapConvKey(client, conversationId, own, version);
|
||||
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
|
||||
if (cached) return cached;
|
||||
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||||
if (!bundle) return null;
|
||||
const key = await unwrapConvKey(
|
||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||||
);
|
||||
let key: Uint8Array;
|
||||
try {
|
||||
key = await unwrapConvKey(
|
||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||||
);
|
||||
} catch (err) {
|
||||
// Bundle exists but the current private key doesn't unwrap it (typically
|
||||
// after `reset_user_key`). Return null so the caller treats the message
|
||||
// as un-decryptable instead of throwing and killing the whole batch.
|
||||
// The conversation will be auto-rotated to a fresh key on the next send
|
||||
// or chat open via `getOrCreateConvKey`'s own recovery path.
|
||||
console.warn(
|
||||
'[conv-key] tryGetConvKey unwrap failed at v' + keyVersion +
|
||||
' (conv=' + conversationId.slice(0, 8) + ') — marking as un-decryptable',
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const handle = { conversationId, keyVersion, key };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
|
||||
Reference in New Issue
Block a user