diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 29bd7c6..72cb7ad 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -34,6 +34,10 @@ import { unixTimestampSeconds } from "../Utils"; import { getUrlInfo } from "../Utils/link-preview"; +import { + getMessageReportingToken, + shouldIncludeReportingToken +} from "../Utils/reporting-utils"; import { areJidsSameUser, BinaryNode, @@ -747,6 +751,34 @@ export const makeMessagesSocket = (config: SocketConfig) => { logger.debug({ jid }, `adding biz node for buttons message`); } + if ( + !isRetryResend && + shouldIncludeReportingToken(message) && + message.messageContextInfo?.messageSecret + ) { + const reportingKey: WAMessageKey = { + id: msgId, + fromMe: true, + remoteJid: destinationJid, + participant: participant?.jid + }; + + const reportingNode = await getMessageReportingToken( + encodedMsg, + message, + reportingKey + ).catch(err => { + logger.warn({ jid, err }, "failed to attach reporting token"); + + return null; + }); + + if (reportingNode) { + (stanza.content as BinaryNode[]).push(reportingNode); + logger.trace({ jid }, "added reporting token to message"); + } + } + logger.debug( { msgId }, `sending message to ${participants.length} devices` diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 2ffaf44..5d4f0fa 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -143,17 +143,20 @@ export function sha256(buffer: Buffer) { } // HKDF key expansion -export function hkdf( +export const hkdf = ( buffer: Uint8Array | Buffer, expandedLength: number, - info: { salt?: Buffer; info?: string } -) { - return HKDF( - !Buffer.isBuffer(buffer) ? Buffer.from(buffer) : buffer, - expandedLength, - info - ); -} + params: { salt?: Buffer; info?: Buffer | string } +) => { + const key = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + + const normalized = + params.info && Buffer.isBuffer(params.info) + ? { ...params, info: params.info.toString("latin1") } + : params; + + return HKDF(key, expandedLength, normalized); +}; export async function derivePairingCodeKey(pairingCode: string, salt: Buffer) { return await pbkdf2Promise(pairingCode, salt, 2 << 16, 32, "sha256"); diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 49083c7..89f4d1e 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -40,6 +40,8 @@ import { MediaDownloadOptions } from "./messages-media"; import { sha256 } from "./crypto"; +import { shouldIncludeReportingToken } from "./reporting-utils"; +import { randomBytes } from "crypto"; type MediaUploadData = { media: WAMediaUpload; @@ -489,6 +491,13 @@ export const generateWAMessageContent = async ( }; } + if (shouldIncludeReportingToken(m)) { + m.messageContextInfo = m.messageContextInfo || {}; + if (!m.messageContextInfo.messageSecret) { + m.messageContextInfo.messageSecret = randomBytes(32); + } + } + return WAProto.Message.fromObject(m); }; diff --git a/src/Utils/reporting-utils.ts b/src/Utils/reporting-utils.ts new file mode 100644 index 0000000..11725f9 --- /dev/null +++ b/src/Utils/reporting-utils.ts @@ -0,0 +1,405 @@ +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[]; +}; + +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 ENC_SECRET_REPORT_TOKEN = "Report Token"; + +export const shouldIncludeReportingToken = (message: proto.IMessage) => + !message.reactionMessage && + !message.encReactionMessage && + !message.encEventResponseMessage && + !message.pollUpdateMessage; + +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; + + // safety check + if (shift > 35) { + return { value: 0, bytes: 0, ok: false }; + } + } + + return { value: 0, bytes: 0, ok: false }; +}; + +const encodeVarint = (value: number) => { + const parts: number[] = []; + let remaining = value >>> 0; + + while (remaining > 0x7f) { + parts.push((remaining & 0x7f) | 0x80); + remaining >>>= 7; + } + + parts.push(remaining); + return Buffer.from(parts); +}; + +type CompiledReportingField = { + m?: boolean; + children?: Map; +}; + +const compileReportingFields = (fields: readonly ReportingField[]) => { + const map = new Map(); + + fields.forEach(f => { + 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 WIRE = { + VARINT: 0, + FIXED64: 1, + BYTES: 2, + FIXED32: 5 +} as const; + +type FieldBytes = { num: number; bytes: Buffer }; + +/** + * Returns: + * - Buffer (can be length 0) => parsing ok + * - null => parsing error (invalid/truncated wire) + */ +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) => { + if (end > data.length) return false; + out.push({ num: fieldNum, bytes: data.subarray(fieldStart, end) }); + i = end; + return true; + }; + + const skip = (end: number) => { + 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)); +}; + +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 }); +}; + +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 + } + ] + }; +};