From 1d67ba0a548b4a575b2c7c7368390158b06767dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Fri, 10 Jul 2026 14:55:45 +0200 Subject: [PATCH] fix(e2ee): heal orphaned encrypted sidebar previews on deferred decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar preview (conversationMeta.lastMessage) can carry an encryptedPayload + "[OpenPGP-encrypted message]" fallback that persists across launches, but every deferred-decrypt heal path keys off the message stores (in-memory conversation arrays, IndexedDB messages) — none re-decrypt a preview from the ciphertext stashed in the preview itself. When the preview's message is absent or already decrypted in those stores (evicted, healed-in-IndexedDB, or set preview-only by a MAM preview refresh), the preview stays stuck on the fallback until the conversation is opened. Add a symmetric preview-level heal to retryPending(): enumerate previews still carrying encryptedPayload (new getEncryptedPreviews binding) and re-decrypt straight from the stash, applying refreshLastMessageContent. Dedup against handledChatKeys so a preview a store pass already repaired is not re-processed. --- .../src/core/defaultStoreBindings.ts | 10 +++ .../src/core/e2ee/deferredDecrypt.test.ts | 75 +++++++++++++++++++ .../src/core/e2ee/deferredDecrypt.ts | 49 ++++++++++++ packages/fluux-sdk/src/core/test-utils.ts | 6 +- packages/fluux-sdk/src/core/types/client.ts | 6 ++ 5 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/fluux-sdk/src/core/defaultStoreBindings.ts b/packages/fluux-sdk/src/core/defaultStoreBindings.ts index 8c261b16..c3ea36cc 100644 --- a/packages/fluux-sdk/src/core/defaultStoreBindings.ts +++ b/packages/fluux-sdk/src/core/defaultStoreBindings.ts @@ -11,6 +11,7 @@ import { defaultStores, type SDKStores } from '../stores' import type { StoreBindings } from './types/client' +import type { Message } from './types/chat' import { connectionBindingMethodKeys, chatBindingMethodKeys, @@ -114,6 +115,15 @@ export function createDefaultStoreBindings(stores: SDKStores = defaultStores): S Array.from(chatStore.getState().messages, ([id, messages]) => ({ id, messages })), getConversationMessages: (conversationId: string) => chatStore.getState().messages.get(conversationId) ?? [], + getEncryptedPreviews: () => { + const result: Array<{ conversationId: string; lastMessage: Message }> = [] + for (const [conversationId, meta] of chatStore.getState().conversationMeta) { + if (meta.lastMessage?.encryptedPayload) { + result.push({ conversationId, lastMessage: meta.lastMessage }) + } + } + return result + }, }, roster: bindStoreMethods(rosterStore, rosterBindingMethodKeys), console: bindStoreMethods(consoleStore, consoleBindingMethodKeys), diff --git a/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.test.ts b/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.test.ts index dc4bc37e..960cef01 100644 --- a/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.test.ts +++ b/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.test.ts @@ -133,6 +133,81 @@ describe('DeferredDecryptEngine', () => { expect(updates).toMatchObject({ body: 'hello', encryptedPayload: undefined }) }) + it('heals an orphaned encrypted sidebar preview from its own stashed payload', async () => { + // The stuck-preview class: the conversation is NOT loaded (empty + // getAllStoredMessages) and its message is not pending in the durable cache + // (already decrypted there, or evicted) — so neither the in-memory nor the + // durable pass reaches it. The only carrier of the ciphertext is the + // persisted preview itself, which still holds `encryptedPayload`. Without a + // preview-level heal the sidebar stays on "[OpenPGP-encrypted message]" + // until the conversation is opened. + vi.spyOn(manager, 'decryptArchive').mockResolvedValue({ + plaintext: new TextEncoder().encode('hello'), + senderDevice: { jid: 'bob@example.com', deviceId: 'test' }, + securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' }, + }) + + const preview: Message = { + type: 'chat', + id: 'msg-preview', + conversationId: 'bob@example.com', + from: 'bob@example.com', + body: '[OpenPGP-encrypted message]', + timestamp: new Date(), + isOutgoing: false, + encryptedPayload: DUMMY_PAYLOAD_XML, + } + // No loaded messages, nothing pending in the durable cache — the preview is + // the sole carrier of the ciphertext. + stores.chat.getAllStoredMessages.mockReturnValue([]) + stores.chat.getEncryptedPreviews.mockReturnValue([ + { conversationId: 'bob@example.com', lastMessage: preview }, + ]) + + const count = await engine.retryPending() + + expect(count).toBe(1) + expect(stores.chat.refreshLastMessageContent).toHaveBeenCalledTimes(1) + const [conversationId, messageId, updates] = + stores.chat.refreshLastMessageContent.mock.calls[0] + expect(conversationId).toBe('bob@example.com') + expect(messageId).toBe('msg-preview') + expect(updates).toMatchObject({ body: 'hello', encryptedPayload: undefined }) + }) + + it('does not re-decrypt a preview already handled by the message-store pass', async () => { + // When the conversation IS loaded, the in-memory pass decrypts the message + // and heals its preview via updateMessage. The preview-level pass must not + // double-process it: once the store pass clears `encryptedPayload`, + // getEncryptedPreviews no longer returns it. + vi.spyOn(manager, 'decryptArchive').mockResolvedValue({ + plaintext: new TextEncoder().encode('hello'), + senderDevice: { jid: 'bob@example.com', deviceId: 'test' }, + securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' }, + }) + + const pending: Message = { + type: 'chat', + id: 'msg-loaded', + conversationId: 'bob@example.com', + from: 'bob@example.com', + body: '[OpenPGP-encrypted message]', + timestamp: new Date(), + isOutgoing: false, + encryptedPayload: DUMMY_PAYLOAD_XML, + } + stores.chat.getAllStoredMessages.mockReturnValue([ + { id: 'bob@example.com', messages: [pending] }, + ]) + // The store pass cleared the stash, so the preview enumeration returns nothing. + stores.chat.getEncryptedPreviews.mockReturnValue([]) + + await engine.retryPending() + + expect(stores.chat.updateMessage).toHaveBeenCalledTimes(1) + expect(stores.chat.refreshLastMessageContent).not.toHaveBeenCalled() + }) + it('is a no-op when no E2EE manager is available', async () => { engine = new DeferredDecryptEngine({ getManager: () => null, diff --git a/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.ts b/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.ts index 333fbf21..f0b2e1d9 100644 --- a/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.ts +++ b/packages/fluux-sdk/src/core/e2ee/deferredDecrypt.ts @@ -208,6 +208,9 @@ export class DeferredDecryptEngine { const conversationId = msg.conversationId if (!msg.encryptedPayload || !conversationId) continue if (handledChatKeys.has(`${conversationId} ${msg.id}`)) continue + // Record so the preview-heal pass below skips a message this pass + // already repaired (it heals the preview via refreshLastMessageContent). + handledChatKeys.add(`${conversationId} ${msg.id}`) const outcome = await this.decryptSingle( manager, msg.encryptedPayload, msg.from, conversationId, ) @@ -258,6 +261,52 @@ export class DeferredDecryptEngine { } } + // --- Orphaned sidebar previews --- + // A conversation's persisted preview (`conversationMeta.lastMessage`) can + // carry an `encryptedPayload` + "[OpenPGP-encrypted message]" fallback that + // NO message-store pass above reaches: the underlying message may have been + // evicted from IndexedDB, already decrypted there (so the durable scan skips + // it), or set preview-only by a MAM preview refresh that never stored a + // message row. The preview itself is then the sole carrier of the + // ciphertext, and would stay stuck on the fallback until the conversation is + // opened. Re-decrypt straight from the stash and heal the preview in place. + // Runs after the store passes so a preview already healed by them (its + // `encryptedPayload` cleared) is naturally excluded from the enumeration. + for (const { conversationId, lastMessage } of chatBindings.getEncryptedPreviews?.() ?? []) { + if (!lastMessage.encryptedPayload) continue + // Skip a preview whose message a store pass already repaired — that pass + // heals the preview in place (updateMessage / refreshLastMessageContent), + // so re-decrypting here would be redundant work. + if (handledChatKeys.has(`${conversationId} ${lastMessage.id}`)) continue + const outcome = await this.decryptSingle( + manager, lastMessage.encryptedPayload, lastMessage.from, conversationId, + ) + if (outcome.kind === 'decrypted') { + chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, { + body: outcome.body, + ...(outcome.securityContext && { securityContext: outcome.securityContext }), + ...(outcome.attachment && { attachment: outcome.attachment }), + encryptedPayload: undefined, + }) + decryptedCount++ + } else if (outcome.kind === 'unsupported') { + chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, { + encryptedPayload: undefined, + unsupportedEncryption: outcome.info, + }) + } else if (outcome.kind === 'rejected') { + // A preview is always a real previewable message (bodiless-signal + // placeholders are never previewable), so warn with the rejected body. + chatBindings.refreshLastMessageContent?.(conversationId, lastMessage.id, { + body: MESSAGE_REJECTED_BODY, + ...(outcome.securityContext && { securityContext: outcome.securityContext }), + encryptedPayload: undefined, + }) + } + // 'modification' (reaction/retraction) can't be a preview, and 'pending' + // means the key is still locked — leave the stash for a later pass. + } + if (decryptedCount > 0) { logInfo(`E2EE deferred decrypt: successfully decrypted ${decryptedCount} message(s)`) } diff --git a/packages/fluux-sdk/src/core/test-utils.ts b/packages/fluux-sdk/src/core/test-utils.ts index 7eec032a..6e627515 100644 --- a/packages/fluux-sdk/src/core/test-utils.ts +++ b/packages/fluux-sdk/src/core/test-utils.ts @@ -18,7 +18,10 @@ import { // Type utility to convert all function properties to Vitest Mock functions type MockifyFunctions = { - [K in keyof T]: T[K] extends (...args: infer A) => infer R + // NonNullable unwraps OPTIONAL function members (e.g. `getEncryptedPreviews?`) + // so they mockify to a non-optional Mock — createMockStores provides every + // one, matching runtime — instead of falling through as `fn | undefined`. + [K in keyof T]-?: NonNullable extends (...args: infer A) => infer R ? Mock<(...args: A) => R> : T[K] } @@ -649,6 +652,7 @@ export const createMockStores = (): MockStoreBindings => ({ getLastMessage: vi.fn().mockReturnValue(undefined), getAllStoredMessages: vi.fn().mockReturnValue([]), getConversationMessages: vi.fn().mockReturnValue([]), + getEncryptedPreviews: vi.fn().mockReturnValue([]), }, roster: { ...mockMethods(rosterBindingMethodKeys), diff --git a/packages/fluux-sdk/src/core/types/client.ts b/packages/fluux-sdk/src/core/types/client.ts index 61af6a34..e16cbd1d 100644 --- a/packages/fluux-sdk/src/core/types/client.ts +++ b/packages/fluux-sdk/src/core/types/client.ts @@ -93,6 +93,12 @@ export interface StoreBindings { // In-memory messages for a single conversation (archived included). Read // seam for peer-scoped deferred-decrypt retry on a PEP key change. getConversationMessages: (conversationId: string) => Message[] + // Every conversation whose sidebar preview still carries an + // `encryptedPayload` (archived INCLUDED). Read seam for the deferred-decrypt + // engine's preview-level heal: a preview can hold ciphertext that no message + // store reaches (its message evicted, already decrypted in IndexedDB, or set + // preview-only) — so it must be re-decrypted straight from its own stash. + getEncryptedPreviews?: () => Array<{ conversationId: string; lastMessage: Message }> } roster: Pick console: Pick