From bb63d17b583b9daf9a742050870aa1564dc1d72d Mon Sep 17 00:00:00 2001 From: vinikjkkj Date: Tue, 11 Aug 2026 14:46:02 -0300 Subject: [PATCH] fix(protocol): match the own account's hosted devices in isOwnAccountJid WhatsApp delivers hosted sessions addressed as @hosted / @hosted.lid instead of @s.whatsapp.net / @lid. isOwnAccountJid compared the preserved server, so @hosted.lid never matched a meLid of @lid: same account, different server. Every message the account authored from such a device came back with fromMe false. That cascades. selfSentChat is gated on fromMe, so the chat stayed unresolved and remoteJid fell back to the `from` attr, which is the account itself, while remoteJidAlt took sender_pn, which is the account's own number. Consumers then resolved the contact to the connection's own number and collapsed traffic addressed to many different peers into a single thread, stored as received. Both sides are now canonicalized before comparison, mirroring the web bundle: isMeAccount defers to isSameAccountAndAddressingMode, which maps hosted to c.us and hosted.lid to lid and then compares the user alone, ignoring the device. Note this only helps when meLid is populated - with an empty meLid there is nothing for a canonicalized LID to match against. buildIncomingMessageKey also stops gating the recipient promotion on the chat resolving. When a 1:1 stanza is self-authored the sender attrs describe the own account whether or not the chat resolved, so they must never reach remoteJidAlt. The msmsg and unavailable call sites pass no destinationJid, so that path is reachable. toUserJid keeps its zero-allocation fast path under canonicalization when the server is not hosted, since canonicalization rewrites nothing else - without it every identity comparison paid a parse and a slice. isHostedDeviceJid now shares that same in-place check instead of a second tail-anchored scan, which leaves the hosted-server detection in one place. --- .../primitives/__tests__/incoming.test.ts | 52 ++++++++++++++++++ src/message/primitives/incoming.ts | 15 +++-- src/protocol/__tests__/protocol.test.ts | 14 +++++ src/protocol/jid.ts | 55 +++++++++++++------ 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/src/message/primitives/__tests__/incoming.test.ts b/src/message/primitives/__tests__/incoming.test.ts index 5dd6b156..10e95689 100644 --- a/src/message/primitives/__tests__/incoming.test.ts +++ b/src/message/primitives/__tests__/incoming.test.ts @@ -207,6 +207,58 @@ test('1:1 message from my lid identity is detected as fromMe', async () => { assert.equal(key.remoteJid, '144400000000000@lid') }) +test('1:1 message from my hosted device is detected as fromMe', async () => { + const emitted: WaIncomingMessageEvent[] = [] + await handleIncomingMessageAck( + { + tag: 'message', + attrs: { + id: 'msg-self-hosted', + from: '133300000000000:99@hosted.lid', + recipient: '144400000000000@lid', + sender_pn: '5511999999999@s.whatsapp.net', + t: '123' + }, + content: [{ tag: 'enc', attrs: { type: 'msg' }, content: new Uint8Array([1]) }] + }, + createDecryptingOptions(emitted, { + getMeJid: () => '5511999999999@s.whatsapp.net', + getMeLid: () => '133300000000000@lid' + }) + ) + + assert.equal(emitted.length, 1) + const { key } = emitted[0] + assert.equal(key.fromMe, true) + assert.equal(key.remoteJid, '144400000000000@lid') + assert.equal(key.remoteJidAlt, undefined) +}) + +test('self-sent 1:1 message with an unresolved chat keeps the own number out of remoteJidAlt', async () => { + const emitted: WaIncomingMessageEvent[] = [] + await handleIncomingMessageAck( + { + tag: 'message', + attrs: { + id: 'msg-self-no-chat', + from: '133300000000000:99@hosted.lid', + sender_pn: '5511999999999@s.whatsapp.net', + t: '123' + }, + content: [{ tag: 'enc', attrs: { type: 'msg' }, content: new Uint8Array([1]) }] + }, + createDecryptingOptions(emitted, { + getMeJid: () => '5511999999999@s.whatsapp.net', + getMeLid: () => '133300000000000@lid' + }) + ) + + assert.equal(emitted.length, 1) + const { key } = emitted[0] + assert.equal(key.fromMe, true) + assert.equal(key.remoteJidAlt, undefined) +}) + test('1:1 incoming message from a peer stays fromMe false with the peer as remoteJid', async () => { const emitted: WaIncomingMessageEvent[] = [] await handleIncomingMessageAck( diff --git a/src/message/primitives/incoming.ts b/src/message/primitives/incoming.ts index 7b593031..3a87c339 100644 --- a/src/message/primitives/incoming.ts +++ b/src/message/primitives/incoming.ts @@ -88,7 +88,10 @@ type MessageKeyIdentity = Omit /** * Self-authored 1:1 chat is the recipient, so its alternate addressing is the * `recipient*` attrs (the `sender*` attrs describe me). Promotes `recipientAlt` - * to `remoteJidAlt` and drops the stale sender/recipient fields. + * to `remoteJidAlt` and drops the stale sender/recipient fields. Applied + * whenever the stanza is self-authored 1:1, even when the chat itself did not + * resolve: the sender attrs always describe the own account there, so letting + * them through would address the message to the connection's own number. */ function promoteRecipientAddressing(identity: MessageKeyIdentity): MessageKeyIdentity { return { @@ -116,13 +119,13 @@ function buildIncomingMessageKey( const fromMe = sender ? isOwnAccountJid(sender.userJid, options.getMeJid?.(), options.getMeLid?.()) : false - const selfSentChat = - fromMe && !isGroup && !isBroadcast - ? (node.attrs.recipient ?? destinationJid ?? undefined) - : undefined + const isSelfSentDirect = fromMe && !isGroup && !isBroadcast + const selfSentChat = isSelfSentDirect + ? (node.attrs.recipient ?? destinationJid ?? undefined) + : undefined const chatJid = selfSentChat ? toUserJid(selfSentChat) : fromUserJid const { pushName, ...identity } = extractMessageIdentityAttrs(node.attrs) - const keyIdentity = selfSentChat ? promoteRecipientAddressing(identity) : identity + const keyIdentity = isSelfSentDirect ? promoteRecipientAddressing(identity) : identity return { pushName, key: { diff --git a/src/protocol/__tests__/protocol.test.ts b/src/protocol/__tests__/protocol.test.ts index 3bb1d261..0d6d8e1b 100644 --- a/src/protocol/__tests__/protocol.test.ts +++ b/src/protocol/__tests__/protocol.test.ts @@ -155,6 +155,13 @@ test('jid type detection and device handling', () => { assert.equal(isOwnAccountJid('5599@s.whatsapp.net', '5511@s.whatsapp.net', '1330@lid'), false) assert.equal(isOwnAccountJid('5511@s.whatsapp.net', null, null), false) + // A hosted device of the account is the account (wa-web isSameAccountAndAddressingMode). + assert.equal(isOwnAccountJid('1330:99@hosted.lid', '5511@s.whatsapp.net', '1330@lid'), true) + assert.equal(isOwnAccountJid('5511:99@hosted', '5511@s.whatsapp.net', '1330@lid'), true) + assert.equal(isOwnAccountJid('1330:99@hosted.lid', '5511@s.whatsapp.net', null), false) + assert.equal(isOwnAccountJid('9999:99@hosted.lid', '5511@s.whatsapp.net', '1330@lid'), false) + assert.equal(isOwnAccountJid('1330@lid', '5511:99@hosted', '1330:99@hosted.lid'), true) + assert.equal(normalizeDeviceJid('5511:0@s.whatsapp.net'), '5511@s.whatsapp.net') assert.equal(normalizeDeviceJid('5511:5@s.whatsapp.net'), '5511:5@s.whatsapp.net') @@ -187,6 +194,13 @@ test('jid type detection and device handling', () => { assert.equal(isHostedDeviceJid('5511:99@hosted.lid'), true) assert.equal(isHostedDeviceJid('5511:99@lid'), true) assert.equal(isHostedDeviceJid('5511:1@lid'), false) + // Detected by server alone, and malformed shapes still rejected. + assert.equal(isHostedDeviceJid('5511@hosted'), true) + assert.equal(isHostedDeviceJid('5511@hosted.lid'), true) + assert.equal(isHostedDeviceJid('a@b@hosted'), false) + assert.equal(isHostedDeviceJid('@hosted'), false) + assert.equal(isHostedDeviceJid('5511@hostedd'), false) + assert.equal(isHostedDeviceJid('5511@hosted.li'), false) assert.equal( buildDeviceJid('6116570308623', 'lid', 99, { diff --git a/src/protocol/jid.ts b/src/protocol/jid.ts index db5fe99a..d464af84 100644 --- a/src/protocol/jid.ts +++ b/src/protocol/jid.ts @@ -211,6 +211,23 @@ export function canonicalizeSignalJid( return `${address.user}:${address.device}@${server}` } +/** + * Returns `true` when the server segment of `jid` starting at `from` is one of + * the hosted variants. Compares in place, so the common miss costs one length + * check and allocates nothing. Callers that already located the `@` pass its + * index rather than paying {@link isJidType}'s tail-anchored rescan. + */ +function isHostedServerAt(jid: string, from: number): boolean { + const length = jid.length - from + if (length === WA_DEFAULTS.HOSTED_SERVER.length) { + return jid.startsWith(WA_DEFAULTS.HOSTED_SERVER, from) + } + if (length === WA_DEFAULTS.HOSTED_LID_SERVER.length) { + return jid.startsWith(WA_DEFAULTS.HOSTED_LID_SERVER, from) + } + return false +} + /** * Strips the `:device` segment from a JID, returning the bare `user@server` * form. Set `options.canonicalizeSignalServer` to also rewrite hosted servers @@ -224,13 +241,17 @@ export function toUserJid( } = {} ): string { const canonicalize = options.canonicalizeSignalServer === true - if (!canonicalize) { - const atIndex = jid.indexOf('@') - if (atIndex >= 1 && atIndex < jid.length - 1) { - const colonIndex = jid.indexOf(':', 0) - if (colonIndex === -1 || colonIndex > atIndex) { - return jid - } + const atIndex = jid.indexOf('@') + if (atIndex >= 1 && atIndex < jid.length - 1) { + const colonIndex = jid.indexOf(':', 0) + // Canonicalization only rewrites the hosted servers, so a deviceless JID + // on any other server already is its own target form and can skip the + // parse (which would slice the user out and allocate an address). + if ( + (colonIndex === -1 || colonIndex > atIndex) && + (!canonicalize || !isHostedServerAt(jid, atIndex + 1)) + ) { + return jid } } const address = parseSignalAddressFromJid(jid) @@ -244,20 +265,25 @@ export function toUserJid( return `${address.user}@${server}` } +const CANONICAL_USER_JID_OPTIONS = Object.freeze({ canonicalizeSignalServer: true } as const) + /** * True when `jid` is the account's own user, matching the `meJid` (pn) or * `meLid` (lid) identity device-insensitively. Mirrors WhatsApp Web's - * `isMeAccount`. + * `isMeAccount`, including its addressing-mode equivalence: a hosted device of + * the account (`@hosted` / `@hosted.lid`) is the same account as + * `@s.whatsapp.net` / `@lid`, so both sides are canonicalized + * before comparison. */ export function isOwnAccountJid( jid: string, meJid: string | null | undefined, meLid: string | null | undefined ): boolean { - const candidateUser = toUserJid(jid) + const candidateUser = toUserJid(jid, CANONICAL_USER_JID_OPTIONS) return ( - (!!meJid && toUserJid(meJid) === candidateUser) || - (!!meLid && toUserJid(meLid) === candidateUser) + (!!meJid && toUserJid(meJid, CANONICAL_USER_JID_OPTIONS) === candidateUser) || + (!!meLid && toUserJid(meLid, CANONICAL_USER_JID_OPTIONS) === candidateUser) ) } @@ -328,14 +354,9 @@ export function isHostedServer(server: string): boolean { * (`@hosted` / `@hosted.lid`) or by a `:HOSTED_DEVICE_ID@…` device segment. */ export function isHostedDeviceJid(jid: string): boolean { - if ( - isJidType(jid, WA_DEFAULTS.HOSTED_SERVER) || - isJidType(jid, WA_DEFAULTS.HOSTED_LID_SERVER) - ) { - return true - } const atIndex = jid.indexOf('@') if (atIndex < 1 || atIndex >= jid.length - 1) return false + if (isHostedServerAt(jid, atIndex + 1)) return true const colonIndex = jid.indexOf(':') if (colonIndex < 0 || colonIndex >= atIndex - 1) return false let deviceId = 0