diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 61fb4860..3bd814b1 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -35,6 +35,7 @@ import { } from '../Utils' import { getUrlInfo } from '../Utils/link-preview' import { makeKeyedMutex } from '../Utils/make-mutex' +import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils' import { areJidsSameUser, type BinaryNode, @@ -624,6 +625,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const destinationJid = !isStatus ? finalJid : statusJid const binaryNodeContent: BinaryNode[] = [] const devices: DeviceWithJid[] = [] + let reportingMessage: proto.IMessage | undefined const meMsg: proto.IMessage = { deviceSentMessage: { @@ -734,6 +736,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { } const bytes = encodeWAMessage(patched) + reportingMessage = patched const groupAddressingMode = additionalAttributes?.['addressing_mode'] || groupData?.addressingMode || 'lid' const groupSenderIdentity = groupAddressingMode === 'lid' && meLid ? meLid : meId @@ -798,6 +801,12 @@ export const makeMessagesSocket = (config: SocketConfig) => { } const { user: ownUser } = jidDecode(ownId)! + if (!participant) { + const patchedForReporting = await patchMessageBeforeSending(message, [jid]) + reportingMessage = Array.isArray(patchedForReporting) + ? patchedForReporting.find(item => item.recipientJid === jid) || patchedForReporting[0] + : patchedForReporting + } if (!isRetryResend) { const targetUserServer = isLid ? 'lid' : 's.whatsapp.net' @@ -970,6 +979,30 @@ export const makeMessagesSocket = (config: SocketConfig) => { logger.debug({ jid }, 'adding device identity') } + if ( + !isNewsletter && + !isRetryResend && + reportingMessage?.messageContextInfo?.messageSecret && + shouldIncludeReportingToken(reportingMessage) + ) { + try { + const encoded = encodeWAMessage(reportingMessage) + const reportingKey: WAMessageKey = { + id: msgId, + fromMe: true, + remoteJid: destinationJid, + participant: participant?.jid + } + const reportingNode = await getMessageReportingToken(encoded, reportingMessage, reportingKey) + if (reportingNode) { + ;(stanza.content as BinaryNode[]).push(reportingNode) + logger.trace({ jid }, 'added reporting token to message') + } + } catch (error: any) { + logger.warn({ jid, trace: error?.stack }, 'failed to attach reporting token') + } + } + const contactTcTokenData = !isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {} diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index a92aa6ed..8217004d 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -40,6 +40,7 @@ import { getRawMediaUploadData, type MediaDownloadOptions } from './messages-media' +import { shouldIncludeReportingToken } from './reporting-utils' type MediaUploadData = { media: WAMediaUpload @@ -617,6 +618,13 @@ export const generateWAMessageContent = async ( } } + if (shouldIncludeReportingToken(m)) { + m.messageContextInfo = m.messageContextInfo || {} + if (!m.messageContextInfo.messageSecret) { + m.messageContextInfo.messageSecret = randomBytes(32) + } + } + return WAProto.Message.create(m) } diff --git a/src/Utils/reporting-utils.ts b/src/Utils/reporting-utils.ts new file mode 100644 index 00000000..2c45ca56 --- /dev/null +++ b/src/Utils/reporting-utils.ts @@ -0,0 +1,344 @@ +import { createHmac } from 'crypto' +import { proto } from '../../WAProto/index.js' +import type { WAMessageContent, WAMessageKey } from '../Types' +import type { BinaryNode } from '../WABinary' +import { hkdf } from './crypto' + +export type ReportingField = { + f: number + m?: boolean + s?: ReportingField[] +} + +type CompiledReportingField = { + m?: boolean + children?: Map +} + +const reportingFields: ReportingField[] = [ + { f: 1 }, + { + f: 3, + s: [{ f: 2 }, { f: 3 }, { f: 8 }, { f: 11 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 25 }] + }, + { f: 4, s: [{ f: 1 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 5, s: [{ f: 3 }, { f: 4 }, { f: 5 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 6, s: [{ f: 1 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 30 }] }, + { f: 7, s: [{ f: 2 }, { f: 7 }, { f: 10 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] }, + { f: 8, s: [{ f: 2 }, { f: 7 }, { f: 9 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 21 }] }, + { f: 9, s: [{ f: 2 }, { f: 6 }, { f: 7 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] }, + { f: 12, s: [{ f: 1 }, { f: 2 }, { f: 14, m: true }, { f: 15 }] }, + { f: 18, s: [{ f: 6 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 26, s: [{ f: 4 }, { f: 5 }, { f: 8 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 28, s: [{ f: 1 }, { f: 2 }, { f: 4 }, { f: 5 }, { f: 6 }, { f: 7, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 37, s: [{ f: 1, m: true }] }, + { + f: 49, + s: [ + { f: 2 }, + { f: 3, s: [{ f: 1 }, { f: 2 }] }, + { f: 5, s: [{ f: 21 }, { f: 22 }] }, + { f: 8, s: [{ f: 1 }, { f: 2 }] } + ] + }, + { f: 53, s: [{ f: 1, m: true }] }, + { f: 55, s: [{ f: 1, m: true }] }, + { f: 58, s: [{ f: 1, m: true }] }, + { f: 59, s: [{ f: 1, m: true }] }, + { + f: 60, + s: [ + { f: 2 }, + { f: 3, s: [{ f: 1 }, { f: 2 }] }, + { f: 5, s: [{ f: 21 }, { f: 22 }] }, + { f: 8, s: [{ f: 1 }, { f: 2 }] } + ] + }, + { + f: 64, + s: [ + { f: 2 }, + { f: 3, s: [{ f: 1 }, { f: 2 }] }, + { f: 5, s: [{ f: 21 }, { f: 22 }] }, + { f: 8, s: [{ f: 1 }, { f: 2 }] } + ] + }, + { f: 66, s: [{ f: 2 }, { f: 6 }, { f: 7 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] }, + { f: 74, s: [{ f: 1, m: true }] }, + { f: 87, s: [{ f: 1, m: true }] }, + { f: 88, s: [{ f: 1 }, { f: 2, s: [{ f: 1 }] }, { f: 3, s: [{ f: 21 }, { f: 22 }] }] }, + { f: 92, s: [{ f: 1, m: true }] }, + { f: 93, s: [{ f: 1, m: true }] }, + { f: 94, s: [{ f: 1, m: true }] } +] + +const compileReportingFields = (fields: readonly ReportingField[]): Map => { + const map = new Map() + for (const f of fields) { + map.set(f.f, { + m: f.m, + children: f.s ? compileReportingFields(f.s) : undefined + }) + } + + return map +} + +const compiledReportingFields = compileReportingFields(reportingFields) +const EMPTY_MAP: Map = new Map() + +const ENC_SECRET_REPORT_TOKEN = 'Report Token' + +const WIRE = { + VARINT: 0, + FIXED64: 1, + BYTES: 2, + FIXED32: 5 +} as const + +export const shouldIncludeReportingToken = (message: proto.IMessage): boolean => + !message.reactionMessage && + !message.encReactionMessage && + !message.encEventResponseMessage && + !message.pollUpdateMessage + +const generateMsgSecretKey = async ( + modificationType: string, + origMsgId: string, + origMsgSender: string, + modificationSender: string, + origMsgSecret: Uint8Array +) => { + const useCaseSecret = Buffer.concat([ + Buffer.from(origMsgId, 'utf8'), + Buffer.from(origMsgSender, 'utf8'), + Buffer.from(modificationSender, 'utf8'), + Buffer.from(modificationType, 'utf8') + ]) + + return hkdf(origMsgSecret, 32, { info: useCaseSecret.toString('latin1') }) +} + +type FieldBytes = { num: number; bytes: Buffer } + +const extractReportingTokenContent = (data: Buffer, cfg: Map): Buffer | null => { + const out: FieldBytes[] = [] + let i = 0 + + while (i < data.length) { + const tag = decodeVarint(data, i) + if (!tag.ok) { + return null + } + + const fieldNum = tag.value >> 3 + const wireType = tag.value & 0x7 + + const fieldStart = i + i += tag.bytes + + const fieldCfg = cfg.get(fieldNum) + + const pushSlice = (end: number): boolean => { + if (end > data.length) { + return false + } + + out.push({ num: fieldNum, bytes: data.subarray(fieldStart, end) }) + i = end + return true + } + + const skip = (end: number): boolean => { + if (end > data.length) { + return false + } + + i = end + return true + } + + if (wireType === WIRE.VARINT) { + const v = decodeVarint(data, i) + if (!v.ok) { + return null + } + + const end = i + v.bytes + + if (!fieldCfg) { + if (!skip(end)) { + return null + } + + continue + } + + if (!pushSlice(end)) { + return null + } + + continue + } + + if (wireType === WIRE.FIXED64) { + const end = i + 8 + + if (!fieldCfg) { + if (!skip(end)) { + return null + } + + continue + } + + if (!pushSlice(end)) { + return null + } + + continue + } + + if (wireType === WIRE.FIXED32) { + const end = i + 4 + + if (!fieldCfg) { + if (!skip(end)) { + return null + } + + continue + } + + if (!pushSlice(end)) { + return null + } + + continue + } + + if (wireType === WIRE.BYTES) { + const len = decodeVarint(data, i) + if (!len.ok) { + return null + } + + const valStart = i + len.bytes + const valEnd = valStart + len.value + if (valEnd > data.length) { + return null + } + + if (!fieldCfg) { + i = valEnd + continue + } + + if (fieldCfg.m || fieldCfg.children) { + const sub = extractReportingTokenContent(data.subarray(valStart, valEnd), fieldCfg.children ?? EMPTY_MAP) + if (sub === null) { + return null + } + + if (sub.length > 0) { + const newTag = encodeVarint(tag.value) + const newLen = encodeVarint(sub.length) + out.push({ + num: fieldNum, + bytes: Buffer.concat([newTag, newLen, sub]) + }) + } + + i = valEnd + continue + } + + out.push({ num: fieldNum, bytes: data.subarray(fieldStart, valEnd) }) + i = valEnd + continue + } + + return null + } + + if (out.length === 0) { + return Buffer.alloc(0) + } + + out.sort((a, b) => a.num - b.num) + return Buffer.concat(out.map(f => f.bytes)) +} + +type Varint = { value: number; bytes: number; ok: boolean } + +const decodeVarint = (buffer: Buffer, offset: number): Varint => { + let value = 0 + let bytes = 0 + let shift = 0 + + while (offset + bytes < buffer.length) { + const current = buffer[offset + bytes]! + value |= (current & 0x7f) << shift + bytes++ + + if ((current & 0x80) === 0) { + return { value, bytes, ok: true } + } + + shift += 7 + + if (shift > 35) { + return { value: 0, bytes: 0, ok: false } + } + } + + return { value: 0, bytes: 0, ok: false } +} + +const encodeVarint = (value: number): Buffer => { + const parts: number[] = [] + let remaining = value >>> 0 + + while (remaining > 0x7f) { + parts.push((remaining & 0x7f) | 0x80) + remaining >>>= 7 + } + + parts.push(remaining) + return Buffer.from(parts) +} + +export const getMessageReportingToken = async ( + msgProtobuf: Buffer, + message: WAMessageContent, + key: WAMessageKey +): Promise => { + const msgSecret = message.messageContextInfo?.messageSecret + if (!msgSecret || !key.id) { + return null + } + + const from = key.fromMe ? key.remoteJid! : key.participant || key.remoteJid! + const to = key.fromMe ? key.participant || key.remoteJid! : key.remoteJid! + + const reportingSecret = await generateMsgSecretKey(ENC_SECRET_REPORT_TOKEN, key.id, from, to, msgSecret) + + const content = extractReportingTokenContent(msgProtobuf, compiledReportingFields) + if (!content || content.length === 0) { + return null + } + + const reportingToken = createHmac('sha256', reportingSecret).update(content).digest().subarray(0, 16) + + return { + tag: 'reporting', + attrs: {}, + content: [ + { + tag: 'reporting_token', + attrs: { v: '2' }, + content: reportingToken + } + ] + } +} diff --git a/src/__tests__/Utils/reporting-utils.test.ts b/src/__tests__/Utils/reporting-utils.test.ts new file mode 100644 index 00000000..f9246a01 --- /dev/null +++ b/src/__tests__/Utils/reporting-utils.test.ts @@ -0,0 +1,160 @@ +import { randomBytes } from 'crypto' +import { proto } from '../../../WAProto' +import type { WAMessageKey } from '../../Types' +import { getMessageReportingToken, shouldIncludeReportingToken } from '../../Utils/reporting-utils' +import type { BinaryNode } from '../../WABinary' + +describe('Reporting Utils', () => { + describe('shouldIncludeReportingToken', () => { + const includedMessages: [string, proto.IMessage][] = [ + ['conversation', { conversation: 'Hello' }], + ['extendedTextMessage', { extendedTextMessage: { text: 'Link' } }], + ['imageMessage', { imageMessage: { url: 'url', mimetype: 'image/jpeg' } }], + ['videoMessage', { videoMessage: { url: 'url', mimetype: 'video/mp4' } }], + ['documentMessage', { documentMessage: { url: 'url', mimetype: 'application/pdf' } }], + ['audioMessage', { audioMessage: { url: 'url', mimetype: 'audio/ogg' } }], + ['stickerMessage', { stickerMessage: { url: 'url', mimetype: 'image/webp' } }] + ] + + const excludedMessages: [string, proto.IMessage][] = [ + ['reactionMessage', { reactionMessage: { key: { id: 'id' }, text: '👍' } }], + [ + 'encReactionMessage', + { + encReactionMessage: { targetMessageKey: { id: 'id' }, encPayload: Buffer.from('x'), encIv: Buffer.from('x') } + } + ], + [ + 'pollUpdateMessage', + { + pollUpdateMessage: { + pollCreationMessageKey: { id: 'id' }, + vote: { encPayload: Buffer.from('x'), encIv: Buffer.from('x') } + } + } + ], + [ + 'encEventResponseMessage', + { + encEventResponseMessage: { + eventCreationMessageKey: { id: 'id' }, + encPayload: Buffer.from('x'), + encIv: Buffer.from('x') + } + } + ] + ] + + it.each(includedMessages)('should return true for %s', (_, message) => { + expect(shouldIncludeReportingToken(message)).toBe(true) + }) + + it.each(excludedMessages)('should return false for %s', (_, message) => { + expect(shouldIncludeReportingToken(message)).toBe(false) + }) + }) + + describe('getMessageReportingToken', () => { + const createKey = (overrides?: Partial): WAMessageKey => ({ + id: 'test-id', + fromMe: true, + remoteJid: '123@s.whatsapp.net', + ...overrides + }) + + const withSecret = (content: proto.IMessage, secret = randomBytes(32)): proto.IMessage => ({ + ...content, + messageContextInfo: { messageSecret: secret } + }) + + const encode = (msg: proto.IMessage) => Buffer.from(proto.Message.encode(msg).finish()) + const getToken = (result: BinaryNode | null): Buffer | undefined => { + const content = result?.content + if (Array.isArray(content) && content[0]) { + return content[0].content as Buffer | undefined + } + + return undefined + } + + it('should return null when message secret is missing', async () => { + const msg: proto.IMessage = { conversation: 'Hello' } + expect(await getMessageReportingToken(encode(msg), msg, createKey())).toBeNull() + }) + + it('should return null when message key id is missing', async () => { + const msg = withSecret({ conversation: 'Hello' }) + expect(await getMessageReportingToken(encode(msg), msg, createKey({ id: '' }))).toBeNull() + }) + + it('should return valid reporting node structure', async () => { + const msg = withSecret({ conversation: 'Hello' }) + const result = await getMessageReportingToken(encode(msg), msg, createKey()) + + expect(result).toMatchObject({ tag: 'reporting', attrs: {} }) + const content = result?.content as any[] + expect(content).toHaveLength(1) + expect(content[0]).toMatchObject({ tag: 'reporting_token', attrs: { v: '2' } }) + expect(content[0].content).toHaveLength(16) + }) + + it('should produce consistent tokens for the same input', async () => { + const secret = randomBytes(32) + const msg = withSecret({ conversation: 'Test' }, secret) + const encoded = encode(msg) + const key = createKey() + + const token1 = getToken(await getMessageReportingToken(encoded, msg, key)) + const token2 = getToken(await getMessageReportingToken(encoded, msg, key)) + expect(token1).toEqual(token2) + }) + + const uniquenessTests: [string, () => [proto.IMessage, WAMessageKey, proto.IMessage, WAMessageKey]][] = [ + [ + 'different secrets', + () => { + const msg1 = withSecret({ conversation: 'Same' }, randomBytes(32)) + const msg2 = withSecret({ conversation: 'Same' }, randomBytes(32)) + return [msg1, createKey(), msg2, createKey()] + } + ], + [ + 'different message IDs', + () => { + const secret = randomBytes(32) + const msg = withSecret({ conversation: 'Same' }, secret) + return [msg, createKey({ id: 'id-1' }), msg, createKey({ id: 'id-2' })] + } + ], + [ + 'different remoteJids', + () => { + const secret = randomBytes(32) + const msg = withSecret({ conversation: 'Same' }, secret) + return [ + msg, + createKey({ remoteJid: '111@s.whatsapp.net' }), + msg, + createKey({ remoteJid: '222@s.whatsapp.net' }) + ] + } + ] + ] + + it.each(uniquenessTests)('should produce different tokens for %s', async (_, setup) => { + const [msg1, key1, msg2, key2] = setup() + const token1 = getToken(await getMessageReportingToken(encode(msg1), msg1, key1)) + const token2 = getToken(await getMessageReportingToken(encode(msg2), msg2, key2)) + expect(token1).not.toEqual(token2) + }) + + it.each([ + ['group messages', { remoteJid: '123@g.us', participant: '456@s.whatsapp.net' }], + ['incoming messages', { fromMe: false }] + ])('should handle %s', async (_, keyOverrides) => { + const msg = withSecret({ conversation: 'Test' }) + const result = await getMessageReportingToken(encode(msg), msg, createKey(keyOverrides)) + expect(result?.tag).toBe('reporting') + }) + }) +})