diff --git a/.gitignore b/.gitignore index 4bf3d82e..bf65bcf8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ bench-profiles-*/ /test/ .claude *.log -___* \ No newline at end of file +___* diff --git a/src/client/coordinators/WaMessageCoordinator.ts b/src/client/coordinators/WaMessageCoordinator.ts index 2797f5ef..984ffde8 100644 --- a/src/client/coordinators/WaMessageCoordinator.ts +++ b/src/client/coordinators/WaMessageCoordinator.ts @@ -28,13 +28,14 @@ import { MEDIA_UPLOAD_PATHS } from '@media/constants' import type { WaMediaTransferClient } from '@media/transfer/WaMediaTransferClient' import type { MediaKind } from '@media/types' import { - buildAddonAdditionalData, + buildAddonSenderPairs, + 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 +849,40 @@ 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( + event.key.fromMe ? event.rawNode.attrs.from : undefined, + modificationSenderRaw, + event.key.participantAlt, + event.key.remoteJidAlt, + event.rawNode.attrs.participant_pn, + event.rawNode.attrs.sender_pn, + event.rawNode.attrs.participant, + event.key.isGroup ? undefined : event.rawNode.attrs.from + ) + + const keyParentSender = resolveAddonParentSenderFromKey( + addon.targetMessageKey, + event.key.isGroup + ) + const parentMsgOriginalSenderCandidates = collectUniqueUserJids( + keyParentSender, + parentEntry.senderJid + ) + const senderPairs = buildAddonSenderPairs({ + parentCandidates: parentMsgOriginalSenderCandidates, + modificationCandidates: modificationSenderCandidates + }) - const plaintext = await decryptAddonPayload({ + const plaintext = await decryptAddonPayloadWithSenderFallback({ messageSecret: parentEntry.secret, stanzaId: targetMessageId, - parentMsgOriginalSender, - modificationSender, + senderPairs, 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 89e49464..9c957bea 100644 --- a/src/client/coordinators/WaMessageDispatchCoordinator.ts +++ b/src/client/coordinators/WaMessageDispatchCoordinator.ts @@ -600,9 +600,11 @@ export class WaMessageDispatchCoordinator { sendOptions.id && (this.deps.persistAllMessageSecrets || needsSecretPersistence(messageWithSecret)) ) { - const meJid = this.deps.getCurrentCredentials()?.meJid ?? '' void this.deps.messageSecretStore - .set(sendOptions.id, { secret: rawSecret, senderJid: meJid }) + .set(sendOptions.id, { + secret: rawSecret, + senderJid: this.resolveOutgoingSecretSenderJid() + }) .catch((error) => { this.deps.logger.warn('failed to persist outgoing message secret', { id: sendOptions.id, @@ -1480,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 78d7fd79..3d0ac1c4 100644 --- a/src/message/crypto/__tests__/addon-crypto.test.ts +++ b/src/message/crypto/__tests__/addon-crypto.test.ts @@ -3,10 +3,14 @@ import test from 'node:test' import { buildAddonAdditionalData, + buildAddonSenderPairs, + collectUniqueUserJids, decodeAddonPlaintext, decryptAddonPayload, + decryptAddonPayloadWithSenderFallback, encryptAddonPayload, - identifyEncryptedAddon + identifyEncryptedAddon, + resolveAddonParentSenderFromKey } from '@message/crypto/addon-crypto' import { createUseCaseSecret, @@ -237,3 +241,281 @@ test('secretEncryptedMessage with non-12-byte iv is not identified', () => { } assert.equal(identifyEncryptedAddon(wrapper), null) }) + +test('resolveAddonParentSenderFromKey reads the author the sender addressed', () => { + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: '142971525722223@lid', fromMe: false, id: 'POLL1' }, + false + ), + '142971525722223@lid' + ) + assert.equal( + resolveAddonParentSenderFromKey( + { + remoteJid: '120363@g.us', + fromMe: false, + id: 'POLL1', + participant: '551100000000:3@s.whatsapp.net' + }, + true + ), + '551100000000@s.whatsapp.net' + ) + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: 'chat@lid', fromMe: true, id: 'POLL1' }, + false + ), + null + ) + assert.equal( + resolveAddonParentSenderFromKey( + { remoteJid: 'not-a-jid', fromMe: false, id: 'POLL1' }, + false + ), + null + ) + assert.equal( + resolveAddonParentSenderFromKey( + { + remoteJid: '120363@g.us', + fromMe: false, + id: 'POLL1', + participant: '@s.whatsapp.net' + }, + true + ), + 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( + 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' + } + ] + ) +}) + +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' + 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, + 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 + }) + 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..b19be7af 100644 --- a/src/message/crypto/addon-crypto.ts +++ b/src/message/crypto/addon-crypto.ts @@ -8,12 +8,20 @@ import { } from '@message/crypto/use-case-secret' import { unwrapMessage } from '@message/encode/content' import { proto, type Proto } from '@proto' +import { + isGroupOrBroadcastJid, + isLidJid, + isNewsletterJid, + isUserJid, + 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 @@ -41,6 +49,147 @@ 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 +): readonly 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 / ...), 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 +): string | null { + if (targetMessageKey.fromMe) return null + const raw = chatIsGroup ? targetMessageKey.participant : targetMessageKey.remoteJid + return collectUniqueUserJids(raw)[0] ?? null +} + +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(isPossibleAddonSenderJid) + const modifiers = input.modificationCandidates.filter(isPossibleAddonSenderJid) + 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, 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 + readonly stanzaId: string + readonly senderPairs: readonly WaAddonSenderPair[] + readonly modificationType: ModificationType + readonly ciphertext: WaAddonBytes + readonly iv: WaAddonBytes +}): Promise { + if (input.senderPairs.length === 0) { + throw new Error('addon sender pairs must not be empty') + } + + let lastError: unknown + 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 + } + } + 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