From 1857af1f379a453868b0e63097713df9f80d758b Mon Sep 17 00:00:00 2001 From: Rodrigo Geller da Silva Date: Thu, 6 Aug 2026 15:46:27 -0300 Subject: [PATCH 1/3] fix: decrypt poll votes across PN and LID parent authors Poll voters encrypt with the creator JID from pollCreationMessageKey, which is typically our LID after the addressing migration. Outgoing secrets were persisted under meJid (PN), so tryDecryptAddon derived the wrong HKDF info and AES-GCM failed with "unable to authenticate data". tryDecryptAddon now prefers the parent author from the target key (same as whatsmeow getOrigSenderFromKey) and retries stored/PN/LID sender candidates on auth failure. New polls also persist the secret under meLid when available so the common path matches what peers encrypt with. --- .../coordinators/WaMessageCoordinator.ts | 43 ++++--- .../WaMessageDispatchCoordinator.ts | 11 +- .../crypto/__tests__/addon-crypto.test.ts | 98 +++++++++++++++- src/message/crypto/addon-crypto.ts | 105 ++++++++++++++++++ 4 files changed, 241 insertions(+), 16 deletions(-) diff --git a/src/client/coordinators/WaMessageCoordinator.ts b/src/client/coordinators/WaMessageCoordinator.ts index 2797f5ef..36e24e9e 100644 --- a/src/client/coordinators/WaMessageCoordinator.ts +++ b/src/client/coordinators/WaMessageCoordinator.ts @@ -28,13 +28,13 @@ import { MEDIA_UPLOAD_PATHS } from '@media/constants' import type { WaMediaTransferClient } from '@media/transfer/WaMediaTransferClient' import type { MediaKind } from '@media/types' import { - buildAddonAdditionalData, + collectUniqueUserJids, decodeAddonPlaintext, - decryptAddonPayload, + decryptAddonPayloadWithSenderFallback, identifyEncryptedAddon, + resolveAddonParentSenderFromKey, resolveParentMessageSecret, - resolvePollOptionNames, - shouldUseAddonAdditionalData + resolvePollOptionNames } from '@message/crypto/addon-crypto' import { unwrapMessage } from '@message/encode/content' import { encodeGroupHistoryBundle } from '@message/kinds/group-history' @@ -848,20 +848,37 @@ export class WaMessageCoordinator { return } - const parentMsgOriginalSender = parentEntry.senderJid - const modificationSender = event.key.participant ?? event.key.remoteJid + const modificationSenderRaw = event.key.participant ?? event.key.remoteJid + if (!modificationSenderRaw) return + + const modificationSenderCandidates = collectUniqueUserJids( + modificationSenderRaw, + event.key.participantAlt, + event.key.remoteJidAlt, + event.rawNode.attrs.participant_pn, + event.rawNode.attrs.sender_pn, + event.rawNode.attrs.participant, + event.rawNode.attrs.from + ) + + const keyParentSender = resolveAddonParentSenderFromKey( + addon.targetMessageKey, + event.key.isGroup, + modificationSenderRaw + ) + const parentMsgOriginalSenderCandidates = collectUniqueUserJids( + keyParentSender, + parentEntry.senderJid + ) - const plaintext = await decryptAddonPayload({ + const plaintext = await decryptAddonPayloadWithSenderFallback({ messageSecret: parentEntry.secret, stanzaId: targetMessageId, - parentMsgOriginalSender, - modificationSender, + parentMsgOriginalSenderCandidates, + modificationSenderCandidates, modificationType: addon.modificationType, ciphertext: addon.encPayload, - iv: addon.encIv, - additionalData: shouldUseAddonAdditionalData(addon.modificationType) - ? buildAddonAdditionalData(targetMessageId, modificationSender) - : undefined + iv: addon.encIv }) let decrypted = decodeAddonPlaintext(addon.kind, plaintext) diff --git a/src/client/coordinators/WaMessageDispatchCoordinator.ts b/src/client/coordinators/WaMessageDispatchCoordinator.ts index eb681c75..523f03c3 100644 --- a/src/client/coordinators/WaMessageDispatchCoordinator.ts +++ b/src/client/coordinators/WaMessageDispatchCoordinator.ts @@ -600,9 +600,16 @@ export class WaMessageDispatchCoordinator { sendOptions.id && (this.deps.persistAllMessageSecrets || needsSecretPersistence(messageWithSecret)) ) { - const meJid = this.deps.getCurrentCredentials()?.meJid ?? '' + const credentials = this.deps.getCurrentCredentials() + // Prefer LID: peers address us by LID in pollCreationMessageKey during + // the PN→LID migration, and HKDF binds the parent-author JID. + const senderForSecret = credentials?.meLid + ? toUserJid(credentials.meLid) + : credentials?.meJid + ? toUserJid(credentials.meJid) + : '' void this.deps.messageSecretStore - .set(sendOptions.id, { secret: rawSecret, senderJid: meJid }) + .set(sendOptions.id, { secret: rawSecret, senderJid: senderForSecret }) .catch((error) => { this.deps.logger.warn('failed to persist outgoing message secret', { id: sendOptions.id, diff --git a/src/message/crypto/__tests__/addon-crypto.test.ts b/src/message/crypto/__tests__/addon-crypto.test.ts index 78d7fd79..ca60d3ee 100644 --- a/src/message/crypto/__tests__/addon-crypto.test.ts +++ b/src/message/crypto/__tests__/addon-crypto.test.ts @@ -3,10 +3,13 @@ import test from 'node:test' import { buildAddonAdditionalData, + collectUniqueUserJids, decodeAddonPlaintext, decryptAddonPayload, + decryptAddonPayloadWithSenderFallback, encryptAddonPayload, - identifyEncryptedAddon + identifyEncryptedAddon, + resolveAddonParentSenderFromKey } from '@message/crypto/addon-crypto' import { createUseCaseSecret, @@ -237,3 +240,96 @@ test('secretEncryptedMessage with non-12-byte iv is not identified', () => { } assert.equal(identifyEncryptedAddon(wrapper), null) }) + +test('resolveAddonParentSenderFromKey matches whatsmeow getOrigSenderFromKey', () => { + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: '142971525722223@lid', fromMe: false, id: 'POLL1' }, + false, + '56410217926709@lid' + ), + '142971525722223@lid' + ) + assert.equal( + resolveAddonParentSenderFromKey( + { + remoteJid: '120363@g.us', + fromMe: false, + id: 'POLL1', + participant: '551100000000:3@s.whatsapp.net' + }, + true, + '56410217926709@lid' + ), + '551100000000@s.whatsapp.net' + ) + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: 'chat@lid', fromMe: true, id: 'POLL1' }, + false, + '56410217926709:49@lid' + ), + '56410217926709@lid' + ) +}) + +test('collectUniqueUserJids strips devices and drops duplicates', () => { + assert.deepEqual( + collectUniqueUserJids( + '56410217926709:49@lid', + '56410217926709@lid', + '5519981790250@s.whatsapp.net', + '', + null, + '5519981790250:1@s.whatsapp.net' + ), + ['56410217926709@lid', '5519981790250@s.whatsapp.net'] + ) +}) + +test('decryptAddonPayloadWithSenderFallback recovers when stored parent is PN but vote used LID', async () => { + const messageSecret = new Uint8Array(32).fill(11) + const stanzaId = '3EB022B08C137DCAE1E401' + const parentLid = '142971525722223@lid' + const parentPn = '5511999999999@s.whatsapp.net' + const voterLid = '56410217926709@lid' + const plaintext = proto.Message.PollVoteMessage.encode({ + selectedOptions: [new Uint8Array(32).fill(3)] + }).finish() + const iv = new Uint8Array(12).fill(5) + + const ciphertext = await encryptAddonPayload({ + messageSecret, + stanzaId, + parentMsgOriginalSender: parentLid, + modificationSender: voterLid, + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + payload: plaintext, + iv + }) + + await assert.rejects( + () => + decryptAddonPayload({ + messageSecret, + stanzaId, + parentMsgOriginalSender: parentPn, + modificationSender: voterLid, + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + ciphertext, + iv + }), + /authenticate|operation failed|decrypt/i + ) + + const recovered = await decryptAddonPayloadWithSenderFallback({ + messageSecret, + stanzaId, + parentMsgOriginalSenderCandidates: [parentPn, parentLid], + modificationSenderCandidates: [voterLid], + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + ciphertext, + iv + }) + assert.deepEqual(Uint8Array.from(recovered), Uint8Array.from(plaintext)) +}) diff --git a/src/message/crypto/addon-crypto.ts b/src/message/crypto/addon-crypto.ts index 5aa1d43d..a4179529 100644 --- a/src/message/crypto/addon-crypto.ts +++ b/src/message/crypto/addon-crypto.ts @@ -8,15 +8,19 @@ import { } from '@message/crypto/use-case-secret' import { unwrapMessage } from '@message/encode/content' import { proto, type Proto } from '@proto' +import { toUserJid } from '@protocol/jid' import type { WaMessageSecretEntry, WaMessageSecretStore } from '@store/contracts/message-secret.store' import type { WaMessageStore } from '@store/contracts/message.store' import { bytesToHex, EMPTY_BYTES, TEXT_ENCODER, toBytesView } from '@util/bytes' +import { toError } from '@util/primitives' const WA_ADDON_ENCRYPTION_NONCE_BYTES = 12 +const ADDON_AUTH_FAILURE_RE = /authenticate|operation failed|decrypt/i + type WaAddonBytes = Uint8Array | ArrayBuffer | ArrayBufferView type ModificationType = @@ -41,6 +45,107 @@ export function buildAddonAdditionalData(stanzaId: string, addOnSenderJid: strin return TEXT_ENCODER.encode(`${stanzaId}\u0000${addOnSenderJid}`) } +/** + * Deduplicates JID candidates as bare `user@server` (device stripped). Empty / + * whitespace-only values are dropped. Order is preserved (first wins). + */ +export function collectUniqueUserJids( + ...candidates: ReadonlyArray +): string[] { + const out: string[] = [] + const seen = new Set() + for (const candidate of candidates) { + if (typeof candidate !== 'string' || !candidate.trim()) continue + let userJid: string + try { + userJid = toUserJid(candidate) + } catch { + continue + } + if (seen.has(userJid)) continue + seen.add(userJid) + out.push(userJid) + } + return out +} + +/** + * Resolves the parent-message author JID the peer used when encrypting an + * addon (poll vote / event response / ...). Mirrors whatsmeow + * `getOrigSenderFromKey`: for 1:1 `!fromMe` the key's `remoteJid` is the + * parent author (often our LID); for groups it is `participant`; when + * `fromMe` the parent author is the addon sender themselves. + */ +export function resolveAddonParentSenderFromKey( + targetMessageKey: Proto.IMessageKey, + chatIsGroup: boolean, + modificationSender: string +): string | null { + if (targetMessageKey.fromMe) { + return modificationSender.trim() ? toUserJid(modificationSender) : null + } + if (chatIsGroup) { + const participant = targetMessageKey.participant + return participant?.trim() ? toUserJid(participant) : null + } + const remoteJid = targetMessageKey.remoteJid + return remoteJid?.trim() ? toUserJid(remoteJid) : null +} + +function isAddonAuthFailure(error: unknown): boolean { + return ADDON_AUTH_FAILURE_RE.test(toError(error).message) +} + +/** + * Decrypts an addon payload, retrying across parent-author and modification- + * sender JID candidates. Needed during the PN→LID migration: the voter + * encrypts with the poll creator's LID from `pollCreationMessageKey`, while + * we may have persisted the secret under our PN `meJid`. whatsmeow does the + * same dual-sender retry on GCM auth failure. + */ +export async function decryptAddonPayloadWithSenderFallback(input: { + readonly messageSecret: WaAddonBytes + readonly stanzaId: string + readonly parentMsgOriginalSenderCandidates: readonly string[] + readonly modificationSenderCandidates: readonly string[] + readonly modificationType: ModificationType + readonly ciphertext: WaAddonBytes + readonly iv: WaAddonBytes +}): Promise { + const parents = input.parentMsgOriginalSenderCandidates + const modifiers = input.modificationSenderCandidates + if (parents.length === 0) { + throw new Error('parent message original sender must be a non-empty string') + } + if (modifiers.length === 0) { + throw new Error('modification sender must be a non-empty string') + } + + let lastError: unknown + for (const parentMsgOriginalSender of parents) { + for (const modificationSender of modifiers) { + try { + return await decryptAddonPayload({ + messageSecret: input.messageSecret, + stanzaId: input.stanzaId, + parentMsgOriginalSender, + modificationSender, + modificationType: input.modificationType, + ciphertext: input.ciphertext, + iv: input.iv, + additionalData: shouldUseAddonAdditionalData(input.modificationType) + ? buildAddonAdditionalData(input.stanzaId, modificationSender) + : undefined + }) + } catch (error) { + lastError = error + if (!isAddonAuthFailure(error)) throw error + } + } + } + throw lastError instanceof Error ? lastError : new Error(toError(lastError).message) +} + /** Encrypts an addon payload (poll vote, reaction, edit, ...) with the per-use-case secret. */ export async function encryptAddonPayload(input: { readonly messageSecret: WaAddonBytes From 0649be1a7d97da38809f3bfbe070b11f780d6273 Mon Sep 17 00:00:00 2001 From: Rodrigo Geller da Silva Date: Wed, 12 Aug 2026 08:55:31 -0300 Subject: [PATCH 2/3] fix(message): pair poll vote sender fallbacks --- .gitignore | 3 +- .../coordinators/WaMessageCoordinator.ts | 10 +- .../WaMessageDispatchCoordinator.ts | 47 ++++-- .../__tests__/coordinators.test.ts | 43 +++++- .../crypto/__tests__/addon-crypto.test.ts | 139 ++++++++++++++++-- src/message/crypto/addon-crypto.ts | 104 ++++++++----- 6 files changed, 282 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 4bf3d82e..ac4ad7e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ bench-profiles-*/ /test/ .claude *.log -___* \ No newline at end of file +___* +/docs/ diff --git a/src/client/coordinators/WaMessageCoordinator.ts b/src/client/coordinators/WaMessageCoordinator.ts index 36e24e9e..70240201 100644 --- a/src/client/coordinators/WaMessageCoordinator.ts +++ b/src/client/coordinators/WaMessageCoordinator.ts @@ -28,6 +28,7 @@ import { MEDIA_UPLOAD_PATHS } from '@media/constants' import type { WaMediaTransferClient } from '@media/transfer/WaMediaTransferClient' import type { MediaKind } from '@media/types' import { + buildAddonSenderPairs, collectUniqueUserJids, decodeAddonPlaintext, decryptAddonPayloadWithSenderFallback, @@ -858,7 +859,7 @@ export class WaMessageCoordinator { event.rawNode.attrs.participant_pn, event.rawNode.attrs.sender_pn, event.rawNode.attrs.participant, - event.rawNode.attrs.from + event.key.isGroup ? undefined : event.rawNode.attrs.from ) const keyParentSender = resolveAddonParentSenderFromKey( @@ -870,12 +871,15 @@ export class WaMessageCoordinator { keyParentSender, parentEntry.senderJid ) + const senderPairs = buildAddonSenderPairs({ + parentCandidates: parentMsgOriginalSenderCandidates, + modificationCandidates: modificationSenderCandidates + }) const plaintext = await decryptAddonPayloadWithSenderFallback({ messageSecret: parentEntry.secret, stanzaId: targetMessageId, - parentMsgOriginalSenderCandidates, - modificationSenderCandidates, + senderPairs, modificationType: addon.modificationType, ciphertext: addon.encPayload, iv: addon.encIv diff --git a/src/client/coordinators/WaMessageDispatchCoordinator.ts b/src/client/coordinators/WaMessageDispatchCoordinator.ts index 0a1d1550..9c957bea 100644 --- a/src/client/coordinators/WaMessageDispatchCoordinator.ts +++ b/src/client/coordinators/WaMessageDispatchCoordinator.ts @@ -600,16 +600,11 @@ export class WaMessageDispatchCoordinator { sendOptions.id && (this.deps.persistAllMessageSecrets || needsSecretPersistence(messageWithSecret)) ) { - const credentials = this.deps.getCurrentCredentials() - // Prefer LID: peers address us by LID in pollCreationMessageKey during - // the PN→LID migration, and HKDF binds the parent-author JID. - const senderForSecret = credentials?.meLid - ? toUserJid(credentials.meLid) - : credentials?.meJid - ? toUserJid(credentials.meJid) - : '' void this.deps.messageSecretStore - .set(sendOptions.id, { secret: rawSecret, senderJid: senderForSecret }) + .set(sendOptions.id, { + secret: rawSecret, + senderJid: this.resolveOutgoingSecretSenderJid() + }) .catch((error) => { this.deps.logger.warn('failed to persist outgoing message secret', { id: sendOptions.id, @@ -1487,6 +1482,40 @@ export class WaMessageDispatchCoordinator { return WA_ADDRESSING_MODES.PN } + /** + * Parent-author JID stored with the outgoing message secret. Peers address + * us by LID in `pollCreationMessageKey` during the PN→LID migration, so a + * valid `meLid` wins. A malformed LID must not abort the send: fall back to + * a normalized `meJid`, then to the empty sender (same recoverable path as + * {@link resolveSenderForAddressingMode}). + */ + private resolveOutgoingSecretSenderJid(): string { + const credentials = this.deps.getCurrentCredentials() + const meLid = credentials?.meLid + if (meLid && meLid.includes('@')) { + try { + return toUserJid(meLid) + } catch (error) { + this.deps.logger.trace('ignoring malformed me lid jid', { + meLid, + message: toError(error).message + }) + } + } + const meJid = credentials?.meJid + if (meJid && meJid.includes('@')) { + try { + return toUserJid(meJid) + } catch (error) { + this.deps.logger.trace('ignoring malformed me jid', { + meJid, + message: toError(error).message + }) + } + } + return '' + } + private resolveSenderForAddressingMode( addressingMode: GroupAddressingMode, meJid: string diff --git a/src/client/coordinators/__tests__/coordinators.test.ts b/src/client/coordinators/__tests__/coordinators.test.ts index b178225c..9ae3af32 100644 --- a/src/client/coordinators/__tests__/coordinators.test.ts +++ b/src/client/coordinators/__tests__/coordinators.test.ts @@ -134,6 +134,7 @@ function createMessageDispatchCoordinator( groupMetadataStore: WaGroupMetadataMemoryStore, overrides?: { readonly meJid?: string + readonly meLid?: string readonly mobileMessageIdFormat?: () => boolean readonly serverClock?: ServerClock readonly signalDeviceSync?: { @@ -177,7 +178,9 @@ function createMessageDispatchCoordinator( set: async (_id: string, _entry: { secret: Uint8Array; senderJid: string }) => {} } as never, getCurrentCredentials: () => - overrides?.meJid ? ({ meJid: overrides.meJid } as never) : null, + overrides?.meJid || overrides?.meLid + ? ({ meJid: overrides.meJid, meLid: overrides.meLid } as never) + : null, resolvePrivacyTokenNode: async () => null, onDirectMessageSent: () => undefined, mobileMessageIdFormat: overrides?.mobileMessageIdFormat, @@ -201,6 +204,14 @@ function buildAppStateSyncResult( return { collections } } +function callResolveOutgoingSecretSenderJid(coordinator: WaMessageDispatchCoordinator): string { + return ( + coordinator as unknown as { + resolveOutgoingSecretSenderJid(): string + } + ).resolveOutgoingSecretSenderJid() +} + function callResolvePeerRecipientPn( coordinator: WaMessageDispatchCoordinator, recipientUserJid: string, @@ -229,6 +240,36 @@ test('message dispatch emits message_send with the outbound proto and destinatio assert.ok(events[0]?.message) }) +test('outgoing secret sender prefers meLid and recedes to meJid when lid is malformed', () => { + const store = new WaGroupMetadataMemoryStore() + const withLid = createMessageDispatchCoordinator(store, { + meJid: '5511000000000:12@s.whatsapp.net', + meLid: '123456789:3@lid' + }) + assert.equal(callResolveOutgoingSecretSenderJid(withLid), '123456789@lid') + + const malformedLid = createMessageDispatchCoordinator(store, { + meJid: '5511000000000:12@s.whatsapp.net', + meLid: 'not-a-jid' + }) + assert.equal(callResolveOutgoingSecretSenderJid(malformedLid), '5511000000000@s.whatsapp.net') + + const malformedLidWithAt = createMessageDispatchCoordinator(store, { + meJid: '5511000000000@s.whatsapp.net', + meLid: '@lid' + }) + assert.equal( + callResolveOutgoingSecretSenderJid(malformedLidWithAt), + '5511000000000@s.whatsapp.net' + ) + + const bothMalformed = createMessageDispatchCoordinator(store, { + meJid: 'not-a-jid', + meLid: 'also-bad' + }) + assert.equal(callResolveOutgoingSecretSenderJid(bothMalformed), '') +}) + test('resolvePeerRecipientPn: a PN-addressed caller on a LID envelope stamps that PN', async () => { const coordinator = createMessageDispatchCoordinator(new WaGroupMetadataMemoryStore()) const pn = await callResolvePeerRecipientPn( diff --git a/src/message/crypto/__tests__/addon-crypto.test.ts b/src/message/crypto/__tests__/addon-crypto.test.ts index ca60d3ee..6d74025a 100644 --- a/src/message/crypto/__tests__/addon-crypto.test.ts +++ b/src/message/crypto/__tests__/addon-crypto.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { buildAddonAdditionalData, + buildAddonSenderPairs, collectUniqueUserJids, decodeAddonPlaintext, decryptAddonPayload, @@ -271,19 +272,65 @@ test('resolveAddonParentSenderFromKey matches whatsmeow getOrigSenderFromKey', ( ), '56410217926709@lid' ) + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: 'not-a-jid', fromMe: false, id: 'POLL1' }, + false, + '56410217926709@lid' + ), + null + ) + assert.equal( + resolveAddonParentSenderFromKey( + { + remoteJid: '120363@g.us', + fromMe: false, + id: 'POLL1', + participant: '@s.whatsapp.net' + }, + true, + '56410217926709@lid' + ), + null + ) }) test('collectUniqueUserJids strips devices and drops duplicates', () => { + const collected = collectUniqueUserJids( + '56410217926709:49@lid', + '56410217926709@lid', + '5519981790250@s.whatsapp.net', + '', + null, + '5519981790250:1@s.whatsapp.net' + ) + assert.deepEqual(collected, ['56410217926709@lid', '5519981790250@s.whatsapp.net']) + if (false) { + // @ts-expect-error Candidate lists are immutable to callers. + collected.push('5511777777777@s.whatsapp.net') + } +}) + +test('buildAddonSenderPairs orders LID, PN, and original pairs without mixed or group JIDs', () => { assert.deepEqual( - collectUniqueUserJids( - '56410217926709:49@lid', - '56410217926709@lid', - '5519981790250@s.whatsapp.net', - '', - null, - '5519981790250:1@s.whatsapp.net' - ), - ['56410217926709@lid', '5519981790250@s.whatsapp.net'] + buildAddonSenderPairs({ + parentCandidates: ['5511999999999@s.whatsapp.net', '142971525722223@lid'], + modificationCandidates: [ + '5511888888888@s.whatsapp.net', + '120363000000000000@g.us', + '56410217926709@lid' + ] + }), + [ + { + parentMsgOriginalSender: '142971525722223@lid', + modificationSender: '56410217926709@lid' + }, + { + parentMsgOriginalSender: '5511999999999@s.whatsapp.net', + modificationSender: '5511888888888@s.whatsapp.net' + } + ] ) }) @@ -325,8 +372,78 @@ test('decryptAddonPayloadWithSenderFallback recovers when stored parent is PN bu const recovered = await decryptAddonPayloadWithSenderFallback({ messageSecret, stanzaId, - parentMsgOriginalSenderCandidates: [parentPn, parentLid], - modificationSenderCandidates: [voterLid], + senderPairs: [ + { + parentMsgOriginalSender: parentPn, + modificationSender: voterLid + }, + { + parentMsgOriginalSender: parentLid, + modificationSender: voterLid + } + ], + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + ciphertext, + iv + }) + assert.deepEqual(Uint8Array.from(recovered), Uint8Array.from(plaintext)) +}) + +test('decryptAddonPayloadWithSenderFallback retries paired modification senders without a cross product', async () => { + const messageSecret = new Uint8Array(32).fill(12) + const stanzaId = '3EB022B08C137DCAE1E402' + const parentLid = '142971525722223@lid' + const parentPn = '5511999999999@s.whatsapp.net' + const voterLid = '56410217926709@lid' + const voterPn = '5511888888888@s.whatsapp.net' + const plaintext = proto.Message.PollVoteMessage.encode({ + selectedOptions: [new Uint8Array(32).fill(4)] + }).finish() + const iv = new Uint8Array(12).fill(6) + + const ciphertext = await encryptAddonPayload({ + messageSecret, + stanzaId, + parentMsgOriginalSender: parentLid, + modificationSender: voterLid, + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + payload: plaintext, + iv + }) + + await assert.rejects(() => + decryptAddonPayloadWithSenderFallback({ + messageSecret, + stanzaId, + senderPairs: [ + { + parentMsgOriginalSender: parentPn, + modificationSender: voterPn + }, + { + parentMsgOriginalSender: parentLid, + modificationSender: voterPn + } + ], + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + ciphertext, + iv + }) + ) + + const recovered = await decryptAddonPayloadWithSenderFallback({ + messageSecret, + stanzaId, + senderPairs: [ + { + parentMsgOriginalSender: parentPn, + modificationSender: voterPn + }, + { + parentMsgOriginalSender: parentLid, + modificationSender: voterLid + } + ], modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, ciphertext, iv diff --git a/src/message/crypto/addon-crypto.ts b/src/message/crypto/addon-crypto.ts index a4179529..0126d412 100644 --- a/src/message/crypto/addon-crypto.ts +++ b/src/message/crypto/addon-crypto.ts @@ -8,7 +8,7 @@ import { } from '@message/crypto/use-case-secret' import { unwrapMessage } from '@message/encode/content' import { proto, type Proto } from '@proto' -import { toUserJid } from '@protocol/jid' +import { isLidJid, isUserJid, toUserJid } from '@protocol/jid' import type { WaMessageSecretEntry, WaMessageSecretStore @@ -51,7 +51,7 @@ export function buildAddonAdditionalData(stanzaId: string, addOnSenderJid: strin */ export function collectUniqueUserJids( ...candidates: ReadonlyArray -): string[] { +): readonly string[] { const out: string[] = [] const seen = new Set() for (const candidate of candidates) { @@ -81,21 +81,55 @@ export function resolveAddonParentSenderFromKey( chatIsGroup: boolean, modificationSender: string ): string | null { - if (targetMessageKey.fromMe) { - return modificationSender.trim() ? toUserJid(modificationSender) : null - } - if (chatIsGroup) { - const participant = targetMessageKey.participant - return participant?.trim() ? toUserJid(participant) : null - } - const remoteJid = targetMessageKey.remoteJid - return remoteJid?.trim() ? toUserJid(remoteJid) : null + const raw = targetMessageKey.fromMe + ? modificationSender + : chatIsGroup + ? targetMessageKey.participant + : targetMessageKey.remoteJid + return collectUniqueUserJids(raw)[0] ?? null } function isAddonAuthFailure(error: unknown): boolean { return ADDON_AUTH_FAILURE_RE.test(toError(error).message) } +export interface WaAddonSenderPair { + readonly parentMsgOriginalSender: string + readonly modificationSender: string +} + +/** Builds the bounded LID, PN, and as-received sender rungs used by addon decryption. */ +export function buildAddonSenderPairs(input: { + readonly parentCandidates: readonly string[] + readonly modificationCandidates: readonly string[] +}): readonly WaAddonSenderPair[] { + const parents = input.parentCandidates.filter((jid) => isLidJid(jid) || isUserJid(jid)) + const modifiers = input.modificationCandidates.filter((jid) => isLidJid(jid) || isUserJid(jid)) + const pairs: WaAddonSenderPair[] = [] + const seen = new Set() + const addPair = ( + parentMsgOriginalSender: string | undefined, + modificationSender: string | undefined + ) => { + if (!parentMsgOriginalSender || !modificationSender) return + const key = `${parentMsgOriginalSender}\u0000${modificationSender}` + if (seen.has(key)) return + seen.add(key) + pairs.push({ parentMsgOriginalSender, modificationSender }) + } + + addPair( + parents.find((jid) => isLidJid(jid)), + modifiers.find((jid) => isLidJid(jid)) + ) + addPair( + parents.find((jid) => isUserJid(jid)), + modifiers.find((jid) => isUserJid(jid)) + ) + addPair(parents[0], modifiers[0]) + return pairs +} + /** * Decrypts an addon payload, retrying across parent-author and modification- * sender JID candidates. Needed during the PN→LID migration: the voter @@ -106,41 +140,33 @@ function isAddonAuthFailure(error: unknown): boolean { export async function decryptAddonPayloadWithSenderFallback(input: { readonly messageSecret: WaAddonBytes readonly stanzaId: string - readonly parentMsgOriginalSenderCandidates: readonly string[] - readonly modificationSenderCandidates: readonly string[] + readonly senderPairs: readonly WaAddonSenderPair[] readonly modificationType: ModificationType readonly ciphertext: WaAddonBytes readonly iv: WaAddonBytes }): Promise { - const parents = input.parentMsgOriginalSenderCandidates - const modifiers = input.modificationSenderCandidates - if (parents.length === 0) { - throw new Error('parent message original sender must be a non-empty string') - } - if (modifiers.length === 0) { - throw new Error('modification sender must be a non-empty string') + if (input.senderPairs.length === 0) { + throw new Error('addon sender pairs must not be empty') } let lastError: unknown - for (const parentMsgOriginalSender of parents) { - for (const modificationSender of modifiers) { - try { - return await decryptAddonPayload({ - messageSecret: input.messageSecret, - stanzaId: input.stanzaId, - parentMsgOriginalSender, - modificationSender, - modificationType: input.modificationType, - ciphertext: input.ciphertext, - iv: input.iv, - additionalData: shouldUseAddonAdditionalData(input.modificationType) - ? buildAddonAdditionalData(input.stanzaId, modificationSender) - : undefined - }) - } catch (error) { - lastError = error - if (!isAddonAuthFailure(error)) throw error - } + for (const { parentMsgOriginalSender, modificationSender } of input.senderPairs) { + try { + return await decryptAddonPayload({ + messageSecret: input.messageSecret, + stanzaId: input.stanzaId, + parentMsgOriginalSender, + modificationSender, + modificationType: input.modificationType, + ciphertext: input.ciphertext, + iv: input.iv, + additionalData: shouldUseAddonAdditionalData(input.modificationType) + ? buildAddonAdditionalData(input.stanzaId, modificationSender) + : undefined + }) + } catch (error) { + lastError = error + if (!isAddonAuthFailure(error)) throw error } } throw lastError instanceof Error ? lastError : new Error(toError(lastError).message) From 7c5e5b25e53184c4c8bce2cf7179dad14ad4c90b Mon Sep 17 00:00:00 2001 From: vinikjkkj Date: Wed, 12 Aug 2026 22:55:27 -0300 Subject: [PATCH 3/3] fix(message): decrypt poll votes cast from our own devices A `fromMe` addon stanza is our own vote synced back from another device of this account, so both the parent author and the vote sender are us. The key's `remoteJid` names the chat, i.e. the other side, and both the sender candidate list and the key-derived parent author were reading it, so the pairing settled on the one party that could not have signed the payload and every self-vote failed to authenticate. The parent author now comes only from the stored secret entry on a `fromMe` key, and the stanza's `from` heads the sender candidates there. While in the ladder: keep a sender outside `@lid` / `@s.whatsapp.net`, such as a hosted device, on the as-received rung instead of filtering it out ahead of every rung, and drop the error-message allowlist so a rung that fails for any reason falls through to the next one rather than aborting the walk. Every attempt is local crypto over the same ciphertext, so a failure carries no information worth acting on. Also drops an unrelated /docs/ entry from .gitignore. --- .gitignore | 1 - .../coordinators/WaMessageCoordinator.ts | 4 +- .../crypto/__tests__/addon-crypto.test.ts | 93 ++++++++++++++++--- src/message/crypto/addon-crypto.ts | 70 ++++++++------ 4 files changed, 127 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index ac4ad7e8..bf65bcf8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,3 @@ bench-profiles-*/ .claude *.log ___* -/docs/ diff --git a/src/client/coordinators/WaMessageCoordinator.ts b/src/client/coordinators/WaMessageCoordinator.ts index 70240201..984ffde8 100644 --- a/src/client/coordinators/WaMessageCoordinator.ts +++ b/src/client/coordinators/WaMessageCoordinator.ts @@ -853,6 +853,7 @@ export class WaMessageCoordinator { if (!modificationSenderRaw) return const modificationSenderCandidates = collectUniqueUserJids( + event.key.fromMe ? event.rawNode.attrs.from : undefined, modificationSenderRaw, event.key.participantAlt, event.key.remoteJidAlt, @@ -864,8 +865,7 @@ export class WaMessageCoordinator { const keyParentSender = resolveAddonParentSenderFromKey( addon.targetMessageKey, - event.key.isGroup, - modificationSenderRaw + event.key.isGroup ) const parentMsgOriginalSenderCandidates = collectUniqueUserJids( keyParentSender, diff --git a/src/message/crypto/__tests__/addon-crypto.test.ts b/src/message/crypto/__tests__/addon-crypto.test.ts index 6d74025a..3d0ac1c4 100644 --- a/src/message/crypto/__tests__/addon-crypto.test.ts +++ b/src/message/crypto/__tests__/addon-crypto.test.ts @@ -242,12 +242,11 @@ test('secretEncryptedMessage with non-12-byte iv is not identified', () => { assert.equal(identifyEncryptedAddon(wrapper), null) }) -test('resolveAddonParentSenderFromKey matches whatsmeow getOrigSenderFromKey', () => { +test('resolveAddonParentSenderFromKey reads the author the sender addressed', () => { assert.equal( resolveAddonParentSenderFromKey( { remoteJid: '142971525722223@lid', fromMe: false, id: 'POLL1' }, - false, - '56410217926709@lid' + false ), '142971525722223@lid' ) @@ -259,24 +258,21 @@ test('resolveAddonParentSenderFromKey matches whatsmeow getOrigSenderFromKey', ( id: 'POLL1', participant: '551100000000:3@s.whatsapp.net' }, - true, - '56410217926709@lid' + true ), '551100000000@s.whatsapp.net' ) assert.equal( resolveAddonParentSenderFromKey( { remoteJid: 'chat@lid', fromMe: true, id: 'POLL1' }, - false, - '56410217926709:49@lid' + false ), - '56410217926709@lid' + null ) assert.equal( resolveAddonParentSenderFromKey( { remoteJid: 'not-a-jid', fromMe: false, id: 'POLL1' }, - false, - '56410217926709@lid' + false ), null ) @@ -288,8 +284,7 @@ test('resolveAddonParentSenderFromKey matches whatsmeow getOrigSenderFromKey', ( id: 'POLL1', participant: '@s.whatsapp.net' }, - true, - '56410217926709@lid' + true ), null ) @@ -334,6 +329,80 @@ test('buildAddonSenderPairs orders LID, PN, and original pairs without mixed or ) }) +test('a vote synced from our own device pairs this account with itself', () => { + const us = '142971525722223@lid' + const peer = '56410217926709@lid' + const peerPn = '5511888888888@s.whatsapp.net' + const targetMessageKey = { remoteJid: peer, fromMe: true, id: 'POLL1' } + + assert.equal(resolveAddonParentSenderFromKey(targetMessageKey, false), null) + + const parentCandidates = collectUniqueUserJids( + resolveAddonParentSenderFromKey(targetMessageKey, false), + us + ) + const modificationCandidates = collectUniqueUserJids(us, peer, peerPn) + assert.deepEqual(buildAddonSenderPairs({ parentCandidates, modificationCandidates }), [ + { parentMsgOriginalSender: us, modificationSender: us } + ]) +}) + +test('buildAddonSenderPairs keeps a sender outside lid/pn on the as-received rung', () => { + const hostedSender = '6116570308623@hosted.lid' + assert.deepEqual( + buildAddonSenderPairs({ + parentCandidates: ['142971525722223@lid'], + modificationCandidates: ['120363000000000000@g.us', hostedSender] + }), + [ + { + parentMsgOriginalSender: '142971525722223@lid', + modificationSender: hostedSender + } + ] + ) +}) + +test('decryptAddonPayloadWithSenderFallback walks past a rung that fails before the cipher', async () => { + const messageSecret = new Uint8Array(32).fill(13) + const stanzaId = '3EB022B08C137DCAE1E403' + const parentLid = '142971525722223@lid' + const voterLid = '56410217926709@lid' + const plaintext = proto.Message.PollVoteMessage.encode({ + selectedOptions: [new Uint8Array(32).fill(7)] + }).finish() + const iv = new Uint8Array(12).fill(8) + + const ciphertext = await encryptAddonPayload({ + messageSecret, + stanzaId, + parentMsgOriginalSender: parentLid, + modificationSender: voterLid, + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + payload: plaintext, + iv + }) + + const recovered = await decryptAddonPayloadWithSenderFallback({ + messageSecret, + stanzaId, + senderPairs: [ + { + parentMsgOriginalSender: parentLid, + modificationSender: ' ' + }, + { + parentMsgOriginalSender: parentLid, + modificationSender: voterLid + } + ], + modificationType: WA_USE_CASE_SECRET_MODIFICATION_TYPES.POLL_VOTE, + ciphertext, + iv + }) + assert.deepEqual(Uint8Array.from(recovered), Uint8Array.from(plaintext)) +}) + test('decryptAddonPayloadWithSenderFallback recovers when stored parent is PN but vote used LID', async () => { const messageSecret = new Uint8Array(32).fill(11) const stanzaId = '3EB022B08C137DCAE1E401' diff --git a/src/message/crypto/addon-crypto.ts b/src/message/crypto/addon-crypto.ts index 0126d412..b19be7af 100644 --- a/src/message/crypto/addon-crypto.ts +++ b/src/message/crypto/addon-crypto.ts @@ -8,7 +8,13 @@ import { } from '@message/crypto/use-case-secret' import { unwrapMessage } from '@message/encode/content' import { proto, type Proto } from '@proto' -import { isLidJid, isUserJid, toUserJid } from '@protocol/jid' +import { + isGroupOrBroadcastJid, + isLidJid, + isNewsletterJid, + isUserJid, + toUserJid +} from '@protocol/jid' import type { WaMessageSecretEntry, WaMessageSecretStore @@ -19,8 +25,6 @@ import { toError } from '@util/primitives' const WA_ADDON_ENCRYPTION_NONCE_BYTES = 12 -const ADDON_AUTH_FAILURE_RE = /authenticate|operation failed|decrypt/i - type WaAddonBytes = Uint8Array | ArrayBuffer | ArrayBufferView type ModificationType = @@ -71,40 +75,52 @@ export function collectUniqueUserJids( /** * Resolves the parent-message author JID the peer used when encrypting an - * addon (poll vote / event response / ...). Mirrors whatsmeow - * `getOrigSenderFromKey`: for 1:1 `!fromMe` the key's `remoteJid` is the - * parent author (often our LID); for groups it is `participant`; when - * `fromMe` the parent author is the addon sender themselves. + * addon (poll vote / event response / ...), or `null` when the key carries no + * usable one. In a group the author is the key's `participant`; in 1:1 it is + * `remoteJid`, which is how the sender addressed us (often our LID). + * + * A `fromMe` key is ours, so its author is this account and the stored parent + * entry already holds that JID. `remoteJid` on such a key is the *chat*, i.e. + * the other side, so reading it would seed the candidate list with the one + * party that certainly did not write the parent. + * + * WhatsApp Web reads the author off its own stored parent message instead, + * then normalizes the addressing mode. Taking it from the key the peer sent + * is a shortcut to the same JID, in the exact form they encrypted with, so it + * pairs with the stored author rather than replacing it. */ export function resolveAddonParentSenderFromKey( targetMessageKey: Proto.IMessageKey, - chatIsGroup: boolean, - modificationSender: string + chatIsGroup: boolean ): string | null { - const raw = targetMessageKey.fromMe - ? modificationSender - : chatIsGroup - ? targetMessageKey.participant - : targetMessageKey.remoteJid + if (targetMessageKey.fromMe) return null + const raw = chatIsGroup ? targetMessageKey.participant : targetMessageKey.remoteJid return collectUniqueUserJids(raw)[0] ?? null } -function isAddonAuthFailure(error: unknown): boolean { - return ADDON_AUTH_FAILURE_RE.test(toError(error).message) -} - export interface WaAddonSenderPair { readonly parentMsgOriginalSender: string readonly modificationSender: string } +/** + * Chat JIDs reach the candidate lists through stanza attributes (`from` and + * the `*Alt` fields point at the group in a group chat) and can never be the + * author of anything, so they are dropped. Everything else stays: the + * as-received rung has to work for senders outside `@lid` / `@s.whatsapp.net`, + * such as a hosted device. + */ +function isPossibleAddonSenderJid(jid: string): boolean { + return !isGroupOrBroadcastJid(jid) && !isNewsletterJid(jid) +} + /** Builds the bounded LID, PN, and as-received sender rungs used by addon decryption. */ export function buildAddonSenderPairs(input: { readonly parentCandidates: readonly string[] readonly modificationCandidates: readonly string[] }): readonly WaAddonSenderPair[] { - const parents = input.parentCandidates.filter((jid) => isLidJid(jid) || isUserJid(jid)) - const modifiers = input.modificationCandidates.filter((jid) => isLidJid(jid) || isUserJid(jid)) + const parents = input.parentCandidates.filter(isPossibleAddonSenderJid) + const modifiers = input.modificationCandidates.filter(isPossibleAddonSenderJid) const pairs: WaAddonSenderPair[] = [] const seen = new Set() const addPair = ( @@ -131,11 +147,14 @@ export function buildAddonSenderPairs(input: { } /** - * Decrypts an addon payload, retrying across parent-author and modification- - * sender JID candidates. Needed during the PN→LID migration: the voter - * encrypts with the poll creator's LID from `pollCreationMessageKey`, while - * we may have persisted the secret under our PN `meJid`. whatsmeow does the - * same dual-sender retry on GCM auth failure. + * Decrypts an addon payload, walking the sender rungs until one authenticates. + * Needed during the PN→LID migration: the voter encrypts with the poll + * creator's LID from `pollCreationMessageKey`, while we may have persisted the + * secret under our PN `meJid`. + * + * Every attempt is local crypto over the same ciphertext, so a failed rung + * says nothing beyond "wrong sender pair" and never aborts the walk. Only an + * exhausted list throws, carrying the last error. */ export async function decryptAddonPayloadWithSenderFallback(input: { readonly messageSecret: WaAddonBytes @@ -166,7 +185,6 @@ export async function decryptAddonPayloadWithSenderFallback(input: { }) } catch (error) { lastError = error - if (!isAddonAuthFailure(error)) throw error } } throw lastError instanceof Error ? lastError : new Error(toError(lastError).message)