Skip to content

Commit 0acd58c

Browse files
committed
fix(e2ee): pass isSelfOutgoing in retryDecryptSingle for sent-carbon replays
retryDecryptSingle built the stanza and called decryptStanzaInPlace without the isSelfOutgoing option. For messages where msg.from equals the account's own bare JID (XEP-0280 sent carbons or XEP-0313 MAM self-replays stored before the key was unlocked), the OpenPGP plugin's signcrypt reflection check compared the envelope's <to/> addressee against ownBareJid — but the envelope names the conversation peer — so it threw envelope-reflection and the retry permanently failed. Detect self-outgoing at retry time (getBareJid(senderJid) === ownBareJid) and forward isSelfOutgoing: true to decryptStanzaInPlace, mirroring the logic already correct in the live and MAM paths. Add XMPPClient.e2ee.test.ts with three regression tests: - isSelfOutgoing=true is passed to decryptArchive for own-JID senders - isSelfOutgoing is absent for peer-JID senders - message body and encryptedPayload are updated in the store on success
1 parent 6230fd9 commit 0acd58c

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* XMPPClient E2EE integration tests — retryPendingDecrypts().
3+
*
4+
* Regression guard for: self-outgoing messages stored with encryptedPayload
5+
* (sent carbons or MAM self-replays received before the key was unlocked)
6+
* were permanently undecryptable because retryDecryptSingle did not pass
7+
* isSelfOutgoing: true when senderJid === ownBareJid. Without that flag the
8+
* OpenPGP plugin's signcrypt reflection check compared the envelope's <to/>
9+
* addressee against our own JID, but the envelope names the conversation
10+
* peer — so it threw 'envelope-reflection' and the message stayed broken.
11+
*/
12+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
13+
import { localStorageMock } from './sideEffects.testHelpers'
14+
15+
// chatStore uses persist middleware — needs localStorage before any store import
16+
Object.defineProperty(globalThis, 'localStorage', {
17+
value: localStorageMock,
18+
writable: true,
19+
})
20+
21+
// Prevent IndexedDB operations triggered by chatStore.addMessage / updateMessage
22+
vi.mock('../utils/messageCache', () => ({
23+
saveMessage: vi.fn().mockResolvedValue(undefined),
24+
saveMessages: vi.fn().mockResolvedValue(undefined),
25+
getMessages: vi.fn().mockResolvedValue([]),
26+
getMessage: vi.fn().mockResolvedValue(null),
27+
getMessageByStanzaId: vi.fn().mockResolvedValue(null),
28+
updateMessage: vi.fn().mockResolvedValue(undefined),
29+
deleteMessage: vi.fn().mockResolvedValue(undefined),
30+
deleteConversationMessages: vi.fn().mockResolvedValue(undefined),
31+
clearAllMessages: vi.fn().mockResolvedValue(undefined),
32+
isMessageCacheAvailable: vi.fn().mockReturnValue(false),
33+
getOldestMessageTimestamp: vi.fn().mockResolvedValue(null),
34+
getMessageCount: vi.fn().mockResolvedValue(0),
35+
saveRoomMessage: vi.fn().mockResolvedValue(undefined),
36+
saveRoomMessages: vi.fn().mockResolvedValue(undefined),
37+
getRoomMessages: vi.fn().mockResolvedValue([]),
38+
getRoomMessage: vi.fn().mockResolvedValue(null),
39+
getRoomMessageByStanzaId: vi.fn().mockResolvedValue(null),
40+
updateRoomMessage: vi.fn().mockResolvedValue(undefined),
41+
deleteRoomMessage: vi.fn().mockResolvedValue(undefined),
42+
deleteRoomMessages: vi.fn().mockResolvedValue(undefined),
43+
getRoomMessageCount: vi.fn().mockResolvedValue(0),
44+
}))
45+
46+
import { XMPPClient } from './XMPPClient'
47+
import { chatStore } from '../stores/chatStore'
48+
import {
49+
E2EEManager,
50+
InMemoryStorageBackend,
51+
type XMPPPrimitives,
52+
} from './e2ee'
53+
import { DummyPlaintextPlugin } from './e2ee/DummyPlaintextPlugin'
54+
import { _resetStorageScopeForTesting } from '../utils/storageScope'
55+
56+
function stubXmppPrimitives(): XMPPPrimitives {
57+
return {
58+
sendStanza: async () => {},
59+
queryDisco: async () => ({ features: [], identities: [] }),
60+
publishPEP: async () => {},
61+
retractPEP: async () => {},
62+
deletePEP: async () => {},
63+
queryPEP: async () => [],
64+
subscribePEP: () => ({ unsubscribe: () => {} }),
65+
}
66+
}
67+
68+
async function makeManagerWithDummyPlugin(selfJid: string): Promise<E2EEManager> {
69+
const manager = new E2EEManager({
70+
storage: new InMemoryStorageBackend(),
71+
xmpp: stubXmppPrimitives(),
72+
account: { jid: selfJid },
73+
})
74+
await manager.register(new DummyPlaintextPlugin())
75+
return manager
76+
}
77+
78+
// DummyPlaintextPlugin serialises the payload as:
79+
// <plain xmlns='urn:fluux:e2ee-dummy:0'>base64(plaintext)</plain>
80+
// This is the encryptedPayload string that retryPendingDecrypts() will parse.
81+
const DUMMY_PAYLOAD_XML = `<plain xmlns="urn:fluux:e2ee-dummy:0">aGVsbG8=</plain>` // base64("hello")
82+
83+
describe('XMPPClient.retryPendingDecrypts()', () => {
84+
let xmppClient: XMPPClient
85+
let manager: E2EEManager
86+
87+
beforeEach(async () => {
88+
_resetStorageScopeForTesting()
89+
chatStore.getState().reset()
90+
manager = await makeManagerWithDummyPlugin('me@example.com')
91+
xmppClient = new XMPPClient({ debug: false })
92+
// e2ee is a public field, normally set in handleConnectionSuccess
93+
xmppClient.e2ee = manager
94+
// currentJid is protected; cast to inject without a full connection
95+
;(xmppClient as unknown as { currentJid: string }).currentJid = 'me@example.com/web'
96+
})
97+
98+
afterEach(() => {
99+
vi.clearAllMocks()
100+
chatStore.getState().reset()
101+
})
102+
103+
describe('isSelfOutgoing flag propagation', () => {
104+
it('passes isSelfOutgoing=true to the plugin when the sender equals our own bare JID', async () => {
105+
// A message where from === own bare JID represents a sent carbon or MAM
106+
// self-replay: our other device sent it and the server copied it to us.
107+
chatStore.getState().addConversation({
108+
id: 'bob@example.com',
109+
name: 'Bob',
110+
type: 'chat',
111+
lastMessage: undefined,
112+
unreadCount: 0,
113+
})
114+
chatStore.getState().addMessage({
115+
type: 'chat',
116+
id: 'msg-self-outgoing',
117+
conversationId: 'bob@example.com',
118+
from: 'me@example.com', // own bare JID → self-outgoing
119+
body: '[dummy-plaintext payload]',
120+
timestamp: new Date(),
121+
isOutgoing: true,
122+
encryptedPayload: DUMMY_PAYLOAD_XML,
123+
})
124+
125+
const decryptSpy = vi.spyOn(manager, 'decryptArchive')
126+
await xmppClient.retryPendingDecrypts()
127+
128+
// decryptArchive must have been called exactly once.
129+
expect(decryptSpy).toHaveBeenCalledTimes(1)
130+
131+
// The plugin must receive isSelfOutgoing=true so it inverts its
132+
// reflection check (signcrypt <to/> names the peer, not us).
133+
const [, , context] = decryptSpy.mock.calls[0]
134+
expect(context?.isSelfOutgoing).toBe(true)
135+
})
136+
137+
it('does not set isSelfOutgoing when the sender is the conversation peer', async () => {
138+
chatStore.getState().addConversation({
139+
id: 'bob@example.com',
140+
name: 'Bob',
141+
type: 'chat',
142+
lastMessage: undefined,
143+
unreadCount: 0,
144+
})
145+
chatStore.getState().addMessage({
146+
type: 'chat',
147+
id: 'msg-inbound',
148+
conversationId: 'bob@example.com',
149+
from: 'bob@example.com', // peer JID → normal inbound
150+
body: '[dummy-plaintext payload]',
151+
timestamp: new Date(),
152+
isOutgoing: false,
153+
encryptedPayload: DUMMY_PAYLOAD_XML,
154+
})
155+
156+
const decryptSpy = vi.spyOn(manager, 'decryptArchive')
157+
await xmppClient.retryPendingDecrypts()
158+
159+
expect(decryptSpy).toHaveBeenCalledTimes(1)
160+
const [, , context] = decryptSpy.mock.calls[0]
161+
expect(context?.isSelfOutgoing).not.toBe(true)
162+
})
163+
164+
it('updates the message body and clears encryptedPayload on successful retry', async () => {
165+
// The DummyPlaintextPlugin always returns trust:'untrusted', which
166+
// triggers needsDeferredVerification and prevents count from being
167+
// incremented. To test the end-to-end store update, mock decryptArchive
168+
// to return trust:'verified' so the message is committed to the store.
169+
vi.spyOn(manager, 'decryptArchive').mockResolvedValue({
170+
plaintext: new TextEncoder().encode('hello'),
171+
senderDevice: { jid: 'me@example.com', deviceId: 'test' },
172+
securityContext: { protocolId: 'dummy-plaintext', trust: 'verified' },
173+
})
174+
175+
chatStore.getState().addConversation({
176+
id: 'bob@example.com',
177+
name: 'Bob',
178+
type: 'chat',
179+
lastMessage: undefined,
180+
unreadCount: 0,
181+
})
182+
chatStore.getState().addMessage({
183+
type: 'chat',
184+
id: 'msg-body-check',
185+
conversationId: 'bob@example.com',
186+
from: 'me@example.com',
187+
body: '[dummy-plaintext payload]',
188+
timestamp: new Date(),
189+
isOutgoing: true,
190+
encryptedPayload: DUMMY_PAYLOAD_XML,
191+
})
192+
193+
const count = await xmppClient.retryPendingDecrypts()
194+
195+
expect(count).toBe(1)
196+
// After retry, the message in the store must have the plaintext body
197+
// and no encryptedPayload stash.
198+
const messages = chatStore.getState().messages.get('bob@example.com') ?? []
199+
const msg = messages.find((m) => m.id === 'msg-body-check')
200+
expect(msg?.body).toBe('hello')
201+
expect(msg?.encryptedPayload).toBeUndefined()
202+
})
203+
})
204+
})

packages/fluux-sdk/src/core/XMPPClient.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1679,10 +1679,19 @@ export class XMPPClient {
16791679
const ltx = await import('ltx')
16801680
const encryptedEl = ltx.parse(encryptedPayloadXml) as unknown as Element
16811681

1682-
// Build a minimal stanza wrapper for decryptStanzaInPlace
1682+
// Build a minimal stanza wrapper for decryptStanzaInPlace.
1683+
// Detect self-outgoing (sent carbon or MAM self-replay): when the
1684+
// sender's bare JID equals our own, the signcrypt envelope's <to/>
1685+
// addresses the conversation peer — not us — so the plugin's
1686+
// reflection check must be inverted via isSelfOutgoing.
1687+
const ownBareJid = this.currentJid ? getBareJid(this.currentJid) : ''
1688+
const isSelfOutgoing = ownBareJid !== '' && getBareJid(senderJid) === ownBareJid
16831689
const stanza = xml('message', { from: senderJid }, encryptedEl)
16841690

1685-
const result = await decryptStanzaInPlace(stanza, manager, peer, 'archive')
1691+
const result = await decryptStanzaInPlace(
1692+
stanza, manager, peer, 'archive',
1693+
isSelfOutgoing ? { isSelfOutgoing: true } : undefined,
1694+
)
16861695
if (!result.attempted || result.encryptedPayloadXml) {
16871696
// Still can't decrypt
16881697
return null

0 commit comments

Comments
 (0)