Skip to content

Commit 6230fd9

Browse files
committed
fix(e2ee): upgrade untrusted trust when peer key arrives after decrypt
Messages decrypted before the peer's public key is cached get trust = 'untrusted' because the signature cannot be verified. The in-memory pendingVerifications buffer handles re-verification within a session, but does not persist — messages from previous sessions stay untrusted forever. Three changes fix this: 1. stanzaDecrypt now preserves the encrypted payload when decrypt succeeds but trust is untrusted, enabling later re-verification. 2. E2EEManager exposes an onPeerKeysChanged callback so the host can react when a peer's PEP key material arrives. 3. XMPPClient.retryPendingDecryptsForPeer re-decrypts stashed payloads when the peer key becomes available, and upgrades old messages (without stashed payloads) from untrusted to tofu.
1 parent 21f8062 commit 6230fd9

4 files changed

Lines changed: 142 additions & 6 deletions

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,68 @@ export class XMPPClient {
17211721
}
17221722
}
17231723

1724+
/**
1725+
* Re-attempt deferred decrypts AND upgrade stale trust for a specific
1726+
* peer, triggered when that peer's PEP key material changes.
1727+
*
1728+
* Two categories of stored messages are handled:
1729+
*
1730+
* 1. Messages with `encryptedPayload` — the peer key was not available
1731+
* when the message was first processed, so the signature could not be
1732+
* verified. Re-decrypt now that the key may be cached.
1733+
*
1734+
* 2. Old messages without `encryptedPayload` but with
1735+
* `securityContext.trust === 'untrusted'` and a "not cached" note —
1736+
* these were persisted before the payload-stash fix landed. We cannot
1737+
* re-verify their signatures (the ciphertext is gone), but the
1738+
* decryption + signcrypt envelope validation succeeded, so upgrading
1739+
* to `tofu` is a sound pragmatic trade-off.
1740+
*/
1741+
private async retryPendingDecryptsForPeer(peer: string): Promise<void> {
1742+
const manager = this.e2ee
1743+
if (!manager || !manager.hasPlugins()) return
1744+
if (!this.stores) return
1745+
1746+
const chatBindings = this.stores.chat
1747+
const chatMessages = chatStore.getState().messages
1748+
const peerMessages = chatMessages.get(peer)
1749+
if (!peerMessages) return
1750+
1751+
let updated = 0
1752+
for (const msg of peerMessages) {
1753+
if (msg.encryptedPayload) {
1754+
const result = await this.retryDecryptSingle(
1755+
manager, msg.encryptedPayload, msg.from, peer,
1756+
)
1757+
if (result) {
1758+
chatBindings.updateMessage(peer, msg.id, {
1759+
body: result.body,
1760+
...(result.securityContext && { securityContext: result.securityContext }),
1761+
...(result.attachment && { attachment: result.attachment }),
1762+
encryptedPayload: undefined,
1763+
})
1764+
updated++
1765+
}
1766+
continue
1767+
}
1768+
if (
1769+
msg.securityContext?.trust === 'untrusted' &&
1770+
msg.securityContext.notes?.some((n) => n.includes('not cached'))
1771+
) {
1772+
chatBindings.updateMessage(peer, msg.id, {
1773+
securityContext: {
1774+
protocolId: msg.securityContext.protocolId,
1775+
trust: 'tofu',
1776+
},
1777+
})
1778+
updated++
1779+
}
1780+
}
1781+
if (updated > 0) {
1782+
logInfo(`E2EE peer key change: upgraded ${updated} message(s) for ${peer}`)
1783+
}
1784+
}
1785+
17241786
/**
17251787
* Build the E2EEManager if it doesn't yet exist, or if the previous
17261788
* manager was bound to a different JID. On a plain reconnect/SM-resume
@@ -1764,6 +1826,14 @@ export class XMPPClient {
17641826
this.e2ee.onPluginRegistered((pluginId) => {
17651827
this.emitSDK('e2ee:plugin-registered', { pluginId })
17661828
})
1829+
// When a peer's key material changes (PEP notification), re-attempt
1830+
// deferred decrypts: messages that were decrypted successfully but
1831+
// with untrusted trust (peer key not cached at decrypt time) can now
1832+
// have their signature verified and trust upgraded to tofu/verified.
1833+
// Also upgrade old persisted messages that lack encryptedPayload.
1834+
this.e2ee.onPeerKeysChanged((peer) => {
1835+
void this.retryPendingDecryptsForPeer(peer)
1836+
})
17671837
}
17681838

17691839
/**

packages/fluux-sdk/src/core/e2ee/E2EEManager.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export class E2EEManager {
8686
private readonly securityContextListeners = new Set<SecurityContextUpdateListener>()
8787
private readonly forcedPlaintextConversations = new Set<string>()
8888
private pluginRegisteredCallback: ((pluginId: string) => void) | null = null
89+
private peerKeysChangedCallback: ((peer: BareJID) => void) | null = null
8990
// PEP key-change notifications can race plugin registration: the server
9091
// bursts headline pushes immediately on stream open, but plugins finish
9192
// their async init (IndexedDB hydration, key unwrap) seconds later. We
@@ -200,6 +201,11 @@ export class E2EEManager {
200201
this.pluginRegisteredCallback = cb
201202
}
202203

204+
/** Set a callback invoked whenever a peer's key material changes via PEP. */
205+
onPeerKeysChanged(cb: (peer: BareJID) => void): void {
206+
this.peerKeysChangedCallback = cb
207+
}
208+
203209
/** Force all outbound sends to this target to skip encryption entirely. Inbound decryption is unaffected. */
204210
setForcedPlaintext(target: ConversationTarget, forced: boolean): void {
205211
const key = targetKey(target)
@@ -374,11 +380,12 @@ export class E2EEManager {
374380
} else {
375381
this.enqueuePendingPeerKeyChange(protocolId, peer)
376382
}
377-
return
378-
}
379-
for (const plugin of this.plugins.values()) {
380-
plugin.onPeerKeysChanged?.(peer)
383+
} else {
384+
for (const plugin of this.plugins.values()) {
385+
plugin.onPeerKeysChanged?.(peer)
386+
}
381387
}
388+
this.peerKeysChangedCallback?.(peer)
382389
}
383390

384391
/**

packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ describe('stanzaDecrypt encrypted payload stash on failure', () => {
237237
expect(readStashedEncryptedPayload(stanza)).toBe(result.encryptedPayloadXml)
238238
})
239239

240-
it('does not stash payload on successful decrypt', async () => {
240+
it('does not stash payload on successful decrypt with trusted context', async () => {
241241
const manager = await makeManager(new FakeE2EEPlugin(undefined))
242242
const stanza = buildStanza()
243243

@@ -247,8 +247,54 @@ describe('stanzaDecrypt encrypted payload stash on failure', () => {
247247
expect(result.encryptedPayloadXml).toBeUndefined()
248248
expect(readStashedEncryptedPayload(stanza)).toBeUndefined()
249249
})
250+
251+
it('stashes payload when decrypt succeeds but trust is untrusted', async () => {
252+
const manager = await makeManager(new UntrustedE2EEPlugin())
253+
const stanza = buildStanza()
254+
255+
const result = await decryptStanzaInPlace(stanza, manager, 'peer@example.com')
256+
257+
expect(result.attempted).toBe(true)
258+
// Body should be decrypted
259+
const body = stanza.getChild('body')
260+
expect(body?.children[0]).toBe('decrypted body')
261+
// But encrypted payload should be preserved for later re-verification
262+
expect(result.encryptedPayloadXml).toBeDefined()
263+
expect(result.encryptedPayloadXml).toContain(TEST_NAMESPACE)
264+
expect(readStashedEncryptedPayload(stanza)).toBe(result.encryptedPayloadXml)
265+
// Security context should reflect untrusted
266+
expect(result.securityContext?.trust).toBe('untrusted')
267+
})
250268
})
251269

270+
// ---------------------------------------------------------------------------
271+
// Untrusted plugin: decrypt succeeds but reports untrusted trust (e.g. peer
272+
// key not cached, so signature could not be verified)
273+
// ---------------------------------------------------------------------------
274+
275+
class UntrustedE2EEPlugin extends FakeE2EEPlugin {
276+
constructor() {
277+
super(undefined)
278+
}
279+
280+
override async decrypt(
281+
_h: ConversationHandle,
282+
_payload: EncryptedPayload,
283+
): Promise<DecryptResult> {
284+
const securityContext: SecurityContext = {
285+
protocolId: TEST_PROTOCOL_ID,
286+
trust: 'untrusted',
287+
notes: ['Sender key not cached — signature not checked'],
288+
}
289+
const plaintextXml = serializePayloadEnvelope([xml('body', {}, 'decrypted body')])
290+
return {
291+
plaintext: new TextEncoder().encode(plaintextXml),
292+
senderDevice: { jid: 'peer@example.com', deviceId: 'dev' },
293+
securityContext,
294+
}
295+
}
296+
}
297+
252298
// ---------------------------------------------------------------------------
253299
// Deferred decryption: EME-based stash without plugin
254300
// ---------------------------------------------------------------------------

packages/fluux-sdk/src/core/e2ee/stanzaDecrypt.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ export async function decryptStanzaInPlace(
221221
}
222222
}
223223

224+
// Decrypt succeeded but the plugin reported untrusted trust (e.g. peer
225+
// key not yet cached so the signature could not be verified). Stash
226+
// the encrypted payload so retryPendingDecrypts() can re-verify the
227+
// signature once the peer key arrives — same mechanism used for full
228+
// decrypt failures, but here the body is already correct.
229+
const needsDeferredVerification =
230+
failureReason === null && securityContext?.trust === 'untrusted'
231+
if (needsDeferredVerification) {
232+
stashPayload(stanza, encryptedChildXml)
233+
}
234+
224235
if (securityContext) {
225236
marked[SECURITY_CONTEXT_STASH] = securityContext
226237
}
@@ -233,7 +244,9 @@ export async function decryptStanzaInPlace(
233244
attempted: true,
234245
...(securityContext && { securityContext }),
235246
...(authoredAt && { authoredAt }),
236-
...(failureReason !== null && { encryptedPayloadXml: encryptedChildXml }),
247+
...((failureReason !== null || needsDeferredVerification) && {
248+
encryptedPayloadXml: encryptedChildXml,
249+
}),
237250
}
238251
}
239252

0 commit comments

Comments
 (0)