From 3b46d43bebe2a69a4f910f1757d8d58a2157fb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:45:30 -0300 Subject: [PATCH] fix: harden Protobuf Deserialization and Refactor Utilities (#1820) * refactor: reorganize browser utility functions and improve buffer handling * fix: harden protobuf deserialization and clean up code * refactor: simplify data handling in GroupCipher and SenderKey classes * fix: Ensure consistent Buffer hydration and remove redundant code * refactor: update decodeAndHydrate calls to use proto types for improved type safety --- README.md | 3 + src/Defaults/index.ts | 2 +- src/Signal/Group/group_cipher.ts | 13 +--- src/Signal/Group/sender-chain-key.ts | 11 +-- .../Group/sender-key-distribution-message.ts | 17 ++--- src/Signal/Group/sender-key-message.ts | 8 +-- src/Signal/Group/sender-key-record.ts | 14 +--- src/Signal/Group/sender-key-state.ts | 59 +++------------- src/Socket/messages-recv.ts | 3 +- src/Socket/socket.ts | 5 +- src/Utils/browser-utils.ts | 31 ++++++++ src/Utils/chat-utils.ts | 11 +-- src/Utils/decode-wa-message.ts | 6 +- src/Utils/generics.ts | 53 ++++---------- src/Utils/history.ts | 3 +- src/Utils/messages-media.ts | 3 +- src/Utils/messages.ts | 5 +- src/Utils/noise-handler.ts | 3 +- src/Utils/process-message.ts | 9 ++- src/Utils/proto-utils.ts | 12 ++++ src/Utils/validate-connection.ts | 10 ++- src/WABinary/generic-utils.ts | 3 +- src/__tests__/Utils/generics.test.ts | 70 +++++++++++++++++++ 23 files changed, 194 insertions(+), 160 deletions(-) create mode 100644 src/Utils/browser-utils.ts create mode 100644 src/Utils/proto-utils.ts create mode 100644 src/__tests__/Utils/generics.test.ts diff --git a/README.md b/README.md index 4eef2928..67e41b6a 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,9 @@ sock.ev.on('messages.upsert', ({ messages }) => { > [!NOTE] > This example includes basic auth storage too +> [!NOTE] +> For reliable serialization of the authentication state, especially when storing as JSON, always use the BufferJSON utility. + ```ts import makeWASocket, { DisconnectReason, useMultiFileAuthState } from '@whiskeysockets/baileys' import { Boom } from '@hapi/boom' diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index fec700e7..9bfa00f3 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -1,7 +1,7 @@ import { proto } from '../../WAProto/index.js' import { makeLibSignalRepository } from '../Signal/libsignal' import type { AuthenticationState, SocketConfig, WAVersion } from '../Types' -import { Browsers } from '../Utils' +import { Browsers } from '../Utils/browser-utils' import logger from '../Utils/logger' const version = [2, 3000, 1023223821] diff --git a/src/Signal/Group/group_cipher.ts b/src/Signal/Group/group_cipher.ts index c9e0444f..0f6c7f67 100644 --- a/src/Signal/Group/group_cipher.ts +++ b/src/Signal/Group/group_cipher.ts @@ -19,7 +19,7 @@ export class GroupCipher { this.senderKeyName = senderKeyName } - public async encrypt(paddedPlaintext: Uint8Array | string): Promise { + public async encrypt(paddedPlaintext: Uint8Array): Promise { const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName) if (!record) { throw new Error('No SenderKeyRecord found for encryption') @@ -107,16 +107,9 @@ export class GroupCipher { } } - private async getCipherText( - iv: Uint8Array | string, - key: Uint8Array | string, - plaintext: Uint8Array | string - ): Promise { + private async getCipherText(iv: Uint8Array, key: Uint8Array, plaintext: Uint8Array): Promise { try { - const ivBuffer = typeof iv === 'string' ? Buffer.from(iv, 'base64') : iv - const keyBuffer = typeof key === 'string' ? Buffer.from(key, 'base64') : key - const plaintextBuffer = typeof plaintext === 'string' ? Buffer.from(plaintext) : plaintext - return encrypt(keyBuffer, plaintextBuffer, ivBuffer) + return encrypt(key, plaintext, iv) } catch (e) { throw new Error('InvalidMessageException') } diff --git a/src/Signal/Group/sender-chain-key.ts b/src/Signal/Group/sender-chain-key.ts index ffe11b1a..18d5cbf8 100644 --- a/src/Signal/Group/sender-chain-key.ts +++ b/src/Signal/Group/sender-chain-key.ts @@ -9,16 +9,7 @@ export class SenderChainKey { constructor(iteration: number, chainKey: Uint8Array | Buffer) { this.iteration = iteration - if (Buffer.isBuffer(chainKey)) { - this.chainKey = chainKey - } else if (chainKey instanceof Uint8Array) { - this.chainKey = Buffer.from(chainKey) - } else if (chainKey && typeof chainKey === 'object') { - // backported from @MartinSchere (#1741) - this.chainKey = Buffer.from(Object.values(chainKey)) // temp fix // backported from @MartinSchere (#1741) - } else { - this.chainKey = Buffer.alloc(0) - } + this.chainKey = Buffer.from(chainKey) } public getIteration(): number { diff --git a/src/Signal/Group/sender-key-distribution-message.ts b/src/Signal/Group/sender-key-distribution-message.ts index 2daef092..a14e3f34 100644 --- a/src/Signal/Group/sender-key-distribution-message.ts +++ b/src/Signal/Group/sender-key-distribution-message.ts @@ -1,4 +1,5 @@ import { proto } from '../../../WAProto/index.js' +import { decodeAndHydrate } from '../../Utils/proto-utils' import { CiphertextMessage } from './ciphertext-message' export class SenderKeyDistributionMessage extends CiphertextMessage { @@ -20,19 +21,13 @@ export class SenderKeyDistributionMessage extends CiphertextMessage { if (serialized) { try { const message = serialized.slice(1) - const distributionMessage = proto.SenderKeyDistributionMessage.decode(message) + const distributionMessage = decodeAndHydrate(proto.SenderKeyDistributionMessage, message) this.serialized = serialized this.id = distributionMessage.id this.iteration = distributionMessage.iteration - this.chainKey = - typeof distributionMessage.chainKey === 'string' - ? Buffer.from(distributionMessage.chainKey, 'base64') - : distributionMessage.chainKey - this.signatureKey = - typeof distributionMessage.signingKey === 'string' - ? Buffer.from(distributionMessage.signingKey, 'base64') - : distributionMessage.signingKey + this.chainKey = distributionMessage.chainKey + this.signatureKey = distributionMessage.signingKey } catch (e) { throw new Error(String(e)) } @@ -73,11 +68,11 @@ export class SenderKeyDistributionMessage extends CiphertextMessage { } public getChainKey(): Uint8Array { - return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey + return this.chainKey } public getSignatureKey(): Uint8Array { - return typeof this.signatureKey === 'string' ? Buffer.from(this.signatureKey, 'base64') : this.signatureKey + return this.signatureKey } public getId(): number { diff --git a/src/Signal/Group/sender-key-message.ts b/src/Signal/Group/sender-key-message.ts index 8dc5b724..7be40a2b 100644 --- a/src/Signal/Group/sender-key-message.ts +++ b/src/Signal/Group/sender-key-message.ts @@ -1,5 +1,6 @@ import { calculateSignature, verifySignature } from 'libsignal/src/curve' import { proto } from '../../../WAProto/index.js' +import { decodeAndHydrate } from '../../Utils/proto-utils' import { CiphertextMessage } from './ciphertext-message' export class SenderKeyMessage extends CiphertextMessage { @@ -24,16 +25,13 @@ export class SenderKeyMessage extends CiphertextMessage { const version = serialized[0]! const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH) const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH) - const senderKeyMessage = proto.SenderKeyMessage.decode(message) + const senderKeyMessage = decodeAndHydrate(proto.SenderKeyMessage, message) this.serialized = serialized this.messageVersion = (version & 0xff) >> 4 this.keyId = senderKeyMessage.id this.iteration = senderKeyMessage.iteration - this.ciphertext = - typeof senderKeyMessage.ciphertext === 'string' - ? Buffer.from(senderKeyMessage.ciphertext, 'base64') - : senderKeyMessage.ciphertext + this.ciphertext = senderKeyMessage.ciphertext this.signature = signature } else { const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256 diff --git a/src/Signal/Group/sender-key-record.ts b/src/Signal/Group/sender-key-record.ts index de30182a..dda30c1e 100644 --- a/src/Signal/Group/sender-key-record.ts +++ b/src/Signal/Group/sender-key-record.ts @@ -61,17 +61,9 @@ export class SenderKeyRecord { public serialize(): SenderKeyStateStructure[] { return this.senderKeyStates.map(state => state.getStructure()) } - static deserialize(data: Uint8Array | string | SenderKeyStateStructure[]): SenderKeyRecord { - let parsed: SenderKeyStateStructure[] - if (typeof data === 'string') { - parsed = JSON.parse(data, BufferJSON.reviver) - } else if (data instanceof Uint8Array) { - const str = Buffer.from(data).toString('utf-8') - parsed = JSON.parse(str, BufferJSON.reviver) - } else { - parsed = data - } - + static deserialize(data: Uint8Array): SenderKeyRecord { + const str = Buffer.from(data).toString('utf-8') + const parsed = JSON.parse(str, BufferJSON.reviver) return new SenderKeyRecord(parsed) } } diff --git a/src/Signal/Group/sender-key-state.ts b/src/Signal/Group/sender-key-state.ts index 9791be3a..41297220 100644 --- a/src/Signal/Group/sender-key-state.ts +++ b/src/Signal/Group/sender-key-state.ts @@ -36,13 +36,6 @@ export class SenderKeyState { signatureKeyPrivate?: Uint8Array | string | null, senderKeyStateStructure?: SenderKeyStateStructure | null ) { - const toBuffer = (val: any) => { - if (!val) return Buffer.alloc(0) - if (typeof val === 'string') return Buffer.from(val, 'base64') - if (val instanceof Uint8Array || Array.isArray(val)) return Buffer.from(val) - return Buffer.alloc(0) - } - if (senderKeyStateStructure) { this.senderKeyStateStructure = { ...senderKeyStateStructure, @@ -56,22 +49,16 @@ export class SenderKeyState { signatureKeyPrivate = signatureKeyPair.private } - chainKey = typeof chainKey === 'string' ? Buffer.from(chainKey, 'base64') : chainKey - - const senderChainKeyStructure: SenderChainKeyStructure = { - iteration: iteration || 0, - seed: chainKey ? toBuffer(chainKey) : Buffer.alloc(0) - } - - const signingKeyStructure: SenderSigningKeyStructure = { - public: toBuffer(signatureKeyPublic), - private: signatureKeyPrivate ? toBuffer(signatureKeyPrivate) : undefined - } - this.senderKeyStateStructure = { senderKeyId: id || 0, - senderChainKey: senderChainKeyStructure, - senderSigningKey: signingKeyStructure, + senderChainKey: { + iteration: iteration || 0, + seed: Buffer.from(chainKey || []) + }, + senderSigningKey: { + public: Buffer.from(signatureKeyPublic || []), + private: Buffer.from(signatureKeyPrivate || []) + }, senderMessageKeys: [] } } @@ -96,22 +83,7 @@ export class SenderKeyState { } public getSigningKeyPublic(): Buffer { - let key = this.senderKeyStateStructure.senderSigningKey.public - - // normalize into Buffer - if (!Buffer.isBuffer(key)) { - if (key instanceof Uint8Array) { - key = Buffer.from(key) - } else if (typeof key === 'string') { - key = Buffer.from(key, 'base64') - } else if (key && typeof key === 'object') { - return Buffer.from(Object.values(key)) // temp fix // inspired by @MartinSchere 's #1741 - } else { - key = Buffer.from(key || []) - } - } - - const publicKey = key as Buffer + const publicKey = Buffer.from(this.senderKeyStateStructure.senderSigningKey.public) if (publicKey.length === 32) { const fixed = Buffer.alloc(33) @@ -125,19 +97,6 @@ export class SenderKeyState { public getSigningKeyPrivate(): Buffer | undefined { const privateKey = this.senderKeyStateStructure.senderSigningKey.private - if (!privateKey) { - return undefined - } - - if (Buffer.isBuffer(privateKey)) { - return privateKey - } else if (privateKey instanceof Uint8Array) { - return Buffer.from(privateKey) - } else if (privateKey && typeof privateKey === 'object') { - return Buffer.from(Object.values(privateKey)) // temp fix // inspired by @MartinSchere 's #1741 - } else if (typeof privateKey === 'string') { - return Buffer.from(privateKey, 'base64') - } return Buffer.from(privateKey || []) } diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index b10a37aa..b1d227e7 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -39,6 +39,7 @@ import { xmppSignedPreKey } from '../Utils' import { makeMutex } from '../Utils/make-mutex' +import { decodeAndHydrate } from '../Utils/proto-utils' import { areJidsSameUser, type BinaryNode, @@ -293,7 +294,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { typeof plaintextNode.content === 'string' ? Buffer.from(plaintextNode.content, 'binary') : Buffer.from(plaintextNode.content as Uint8Array) - const messageProto = proto.Message.decode(contentBuf) + const messageProto = decodeAndHydrate(proto.Message, contentBuf) const fullMessage = proto.WebMessageInfo.create({ key: { remoteJid: from, diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index ea22d8b2..bf45d3ab 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -28,11 +28,12 @@ import { getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, - getPlatformId, makeEventBuffer, makeNoiseHandler, promiseTimeout } from '../Utils' +import { getPlatformId } from '../Utils/browser-utils' +import { decodeAndHydrate } from '../Utils/proto-utils' import { assertNodeErrorFree, type BinaryNode, @@ -342,7 +343,7 @@ export const makeSocket = (config: SocketConfig) => { const init = proto.HandshakeMessage.encode(helloMsg).finish() const result = await awaitNextMessage(init) - const handshake = proto.HandshakeMessage.decode(result) + const handshake = decodeAndHydrate(proto.HandshakeMessage, result) logger.trace({ handshake }, 'handshake recv from WA') diff --git a/src/Utils/browser-utils.ts b/src/Utils/browser-utils.ts new file mode 100644 index 00000000..ee2e42dc --- /dev/null +++ b/src/Utils/browser-utils.ts @@ -0,0 +1,31 @@ +import { platform, release } from 'os' +import { proto } from '../../WAProto/index.js' +import type { BrowsersMap } from '../Types' + +const PLATFORM_MAP = { + aix: 'AIX', + darwin: 'Mac OS', + win32: 'Windows', + android: 'Android', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + sunos: 'Solaris', + linux: undefined, + haiku: undefined, + cygwin: undefined, + netbsd: undefined +} + +export const Browsers: BrowsersMap = { + ubuntu: browser => ['Ubuntu', browser, '22.04.4'], + macOS: browser => ['Mac OS', browser, '14.4.1'], + baileys: browser => ['Baileys', browser, '6.5.0'], + windows: browser => ['Windows', browser, '10.0.22631'], + /** The appropriate browser based on your OS & release */ + appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()] +} + +export const getPlatformId = (browser: string) => { + const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any] + return platformType ? platformType.toString() : '1' //chrome +} diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 845b5ce4..b9bbd483 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -25,6 +25,7 @@ import { toNumber } from './generics' import type { ILogger } from './logger' import { LT_HASH_ANTI_TAMPERING } from './lt-hash' import { downloadContentFromMessage } from './messages-media' +import { decodeAndHydrate } from './proto-utils' type FetchAppStateSyncKey = (keyId: string) => Promise @@ -233,7 +234,7 @@ export const decodeSyncdMutations = async ( } const result = aesDecrypt(encContent, key.valueEncryptionKey) - const syncAction = proto.SyncActionData.decode(result) + const syncAction = decodeAndHydrate(proto.SyncActionData, result) if (validateMacs) { const hmac = hmacSign(syncAction.index, key.indexKey) @@ -327,9 +328,9 @@ export const extractSyncdPatches = async (result: BinaryNode, options: AxiosRequ snapshotNode.content = Buffer.from(Object.values(snapshotNode.content)) } - const blobRef = proto.ExternalBlobReference.decode(snapshotNode.content as Buffer) + const blobRef = decodeAndHydrate(proto.ExternalBlobReference, snapshotNode.content as Buffer) const data = await downloadExternalBlob(blobRef, options) - snapshot = proto.SyncdSnapshot.decode(data) + snapshot = decodeAndHydrate(proto.SyncdSnapshot, data) } for (let { content } of patches) { @@ -338,7 +339,7 @@ export const extractSyncdPatches = async (result: BinaryNode, options: AxiosRequ content = Buffer.from(Object.values(content)) } - const syncd = proto.SyncdPatch.decode(content as Uint8Array) + const syncd = decodeAndHydrate(proto.SyncdPatch, content as Uint8Array) if (!syncd.version) { syncd.version = { version: +collectionNode.attrs.version! + 1 } } @@ -366,7 +367,7 @@ export const downloadExternalBlob = async (blob: proto.IExternalBlobReference, o export const downloadExternalPatch = async (blob: proto.IExternalBlobReference, options: AxiosRequestConfig<{}>) => { const buffer = await downloadExternalBlob(blob, options) - const syncData = proto.SyncdMutations.decode(buffer) + const syncData = decodeAndHydrate(proto.SyncdMutations, buffer) return syncData } diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 9755c5d7..22647483 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -16,6 +16,7 @@ import { } from '../WABinary' import { unpadRandomMax16 } from './generics' import type { ILogger } from './logger' +import { decodeAndHydrate } from './proto-utils' const getDecryptionJid = async (sender: string, repository: SignalRepositoryWithLIDStore): Promise => { if (!sender.includes('@s.whatsapp.net')) { @@ -220,7 +221,7 @@ export const decryptMessageNode = ( if (Array.isArray(stanza.content)) { for (const { tag, attrs, content } of stanza.content) { if (tag === 'verified_name' && content instanceof Uint8Array) { - const cert = proto.VerifiedNameCertificate.decode(content) + const cert = decodeAndHydrate(proto.VerifiedNameCertificate, content) const details = proto.VerifiedNameCertificate.Details.decode(cert.details) fullMessage.verifiedBizName = details.verifiedName } @@ -273,7 +274,8 @@ export const decryptMessageNode = ( throw new Error(`Unknown e2e type: ${e2eType}`) } - let msg: proto.IMessage = proto.Message.decode( + let msg: proto.IMessage = decodeAndHydrate( + proto.Message, e2eType !== 'plaintext' ? unpadRandomMax16(msgBuffer) : msgBuffer ) msg = msg.deviceSentMessage?.message || msg diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index f8531152..0c500dc2 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -1,49 +1,13 @@ import { Boom } from '@hapi/boom' import axios, { type AxiosRequestConfig } from 'axios' import { createHash, randomBytes } from 'crypto' -import { platform, release } from 'os' import { proto } from '../../WAProto/index.js' const baileysVersion = [2, 3000, 1023223821] -import type { - BaileysEventEmitter, - BaileysEventMap, - BrowsersMap, - ConnectionState, - WACallUpdateType, - WAVersion -} from '../Types' +import type { BaileysEventEmitter, BaileysEventMap, ConnectionState, WACallUpdateType, WAVersion } from '../Types' import { DisconnectReason } from '../Types' import { type BinaryNode, getAllBinaryNodeChildren, jidDecode } from '../WABinary' import { sha256 } from './crypto' -const PLATFORM_MAP = { - aix: 'AIX', - darwin: 'Mac OS', - win32: 'Windows', - android: 'Android', - freebsd: 'FreeBSD', - openbsd: 'OpenBSD', - sunos: 'Solaris', - linux: undefined, - haiku: undefined, - cygwin: undefined, - netbsd: undefined -} - -export const Browsers: BrowsersMap = { - ubuntu: browser => ['Ubuntu', browser, '22.04.4'], - macOS: browser => ['Mac OS', browser, '14.4.1'], - baileys: browser => ['Baileys', browser, '6.5.0'], - windows: browser => ['Windows', browser, '10.0.22631'], - /** The appropriate browser based on your OS & release */ - appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()] -} - -export const getPlatformId = (browser: string) => { - const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any] - return platformType ? platformType.toString() : '1' //chrome -} - export const BufferJSON = { // eslint-disable-next-line @typescript-eslint/no-explicit-any replacer: (k: any, value: any) => { @@ -56,9 +20,18 @@ export const BufferJSON = { // eslint-disable-next-line @typescript-eslint/no-explicit-any reviver: (_: any, value: any) => { - if (typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) { - const val = value.data || value.value - return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val || []) + if (typeof value === 'object' && value !== null && value.type === 'Buffer' && typeof value.data === 'string') { + return Buffer.from(value.data, 'base64') + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const keys = Object.keys(value) + if (keys.length > 0 && keys.every(k => !isNaN(parseInt(k, 10)))) { + const values = Object.values(value) + if (values.every(v => typeof v === 'number')) { + return Buffer.from(values) + } + } } return value diff --git a/src/Utils/history.ts b/src/Utils/history.ts index a60b0f79..7d8df8ca 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -7,6 +7,7 @@ import { WAMessageStubType } from '../Types' import { toNumber } from './generics' import { normalizeMessageContent } from './messages' import { downloadContentFromMessage } from './messages-media' +import { decodeAndHydrate } from './proto-utils' const inflatePromise = promisify(inflate) @@ -22,7 +23,7 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio // decompress buffer buffer = await inflatePromise(buffer) - const syncData = proto.HistorySync.decode(buffer) + const syncData = decodeAndHydrate(proto.HistorySync, buffer) return syncData } diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index db52fbf0..5660e1fa 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -27,6 +27,7 @@ import { type BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, jidNorma import { aesDecryptGCM, aesEncryptGCM, hkdf } from './crypto' import { generateMessageIDV2 } from './generics' import type { ILogger } from './logger' +import { decodeAndHydrate } from './proto-utils' const getTmpFilesDirectory = () => tmpdir() @@ -783,7 +784,7 @@ export const decryptMediaRetryData = async ( ) => { const retryKey = await getMediaRetryKey(mediaKey) const plaintext = aesDecryptGCM(ciphertext, retryKey, iv, Buffer.from(msgId)) - return proto.MediaRetryNotification.decode(plaintext) + return decodeAndHydrate(proto.MediaRetryNotification, plaintext) } export const getStatusCodeForMediaRetry = (code: number) => diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index f7cd7236..f1b87117 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -40,6 +40,7 @@ import { getRawMediaUploadData, type MediaDownloadOptions } from './messages-media' +import { decodeAndHydrate } from './proto-utils' type MediaUploadData = { media: WAMediaUpload @@ -156,7 +157,7 @@ export const prepareWAMessageMedia = async ( if (mediaBuff) { logger?.debug({ cacheableKey }, 'got media cache hit') - const obj = WAProto.Message.decode(mediaBuff) + const obj = decodeAndHydrate(proto.Message, mediaBuff) const key = `${mediaType}Message` Object.assign(obj[key as keyof proto.Message]!, { ...uploadData, media: undefined }) @@ -339,7 +340,7 @@ export const generateForwardMessageContent = (message: WAMessage, forceForward?: // hacky copy content = normalizeMessageContent(content) - content = proto.Message.decode(proto.Message.encode(content!).finish()) + content = decodeAndHydrate(proto.Message, proto.Message.encode(content!).finish()) let key = Object.keys(content)[0] as keyof proto.IMessage diff --git a/src/Utils/noise-handler.ts b/src/Utils/noise-handler.ts index d0f5e1c1..7fd4b3e4 100644 --- a/src/Utils/noise-handler.ts +++ b/src/Utils/noise-handler.ts @@ -6,6 +6,7 @@ import type { BinaryNode } from '../WABinary' import { decodeBinaryNode } from '../WABinary' import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto' import type { ILogger } from './logger' +import { decodeAndHydrate } from './proto-utils' const generateIV = (counter: number) => { const iv = new ArrayBuffer(12) @@ -112,7 +113,7 @@ export const makeNoiseHandler = ({ const certDecoded = decrypt(serverHello!.payload!) - const { intermediate: certIntermediate } = proto.CertChain.decode(certDecoded) + const { intermediate: certIntermediate } = decodeAndHydrate(proto.CertChain, certDecoded) const { issuerSerial } = proto.CertChain.NoiseCertificate.Details.decode(certIntermediate!.details!) diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index a6d5b6e4..e10d4a70 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -19,6 +19,7 @@ import { aesDecryptGCM, hmacSign } from './crypto' import { toNumber } from './generics' import { downloadAndProcessHistorySyncNotification } from './history' import type { ILogger } from './logger' +import { decodeAndHydrate } from './proto-utils' type ProcessMessageContext = { shouldProcessHistoryMsg: boolean @@ -271,7 +272,7 @@ const processMessage = async ( const { placeholderMessageResendResponse: retryResponse } = result //eslint-disable-next-line max-depth if (retryResponse) { - const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes!) + const webMessageInfo = decodeAndHydrate(proto.WebMessageInfo, retryResponse.webMessageInfoBytes!) // wait till another upsert event is available, don't want it to be part of the PDO response message setTimeout(() => { ev.emit('messages.upsert', { @@ -305,8 +306,10 @@ const processMessage = async ( break case proto.Message.ProtocolMessage.Type.LID_MIGRATION_MAPPING_SYNC: const encodedPayload = protocolMsg.lidMigrationMappingSyncMessage?.encodedMappingPayload! - const { pnToLidMappings, chatDbMigrationTimestamp } = - proto.LIDMigrationMappingSyncPayload.decode(encodedPayload) + const { pnToLidMappings, chatDbMigrationTimestamp } = decodeAndHydrate( + proto.LIDMigrationMappingSyncPayload, + encodedPayload + ) logger?.debug({ pnToLidMappings, chatDbMigrationTimestamp }, 'got lid mappings and chat db migration timestamp') const pairs = [] for (const { pn, latestLid, assignedLid } of pnToLidMappings) { diff --git a/src/Utils/proto-utils.ts b/src/Utils/proto-utils.ts new file mode 100644 index 00000000..cfe53b04 --- /dev/null +++ b/src/Utils/proto-utils.ts @@ -0,0 +1,12 @@ +import { BufferJSON } from './generics' + +type ProtoMessageClass = { + new (...args: any[]): T + decode(buffer: Uint8Array | Buffer): T +} + +export function decodeAndHydrate(MessageType: ProtoMessageClass, buffer: Uint8Array | Buffer): T { + const decoded = MessageType.decode(buffer) + const hydrated = JSON.parse(JSON.stringify(decoded, BufferJSON.replacer), BufferJSON.reviver) + return hydrated as T +} diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index 9406922d..81d7999d 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -6,6 +6,7 @@ import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types' import { type BinaryNode, getBinaryNodeChild, jidDecode, S_WHATSAPP_NET } from '../WABinary' import { Curve, hmacSign } from './crypto' import { encodeBigEndian } from './generics' +import { decodeAndHydrate } from './proto-utils' import { createSignalIdentity } from './signal' const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { @@ -135,7 +136,10 @@ export const configureSuccessfulPairing = ( const jid = deviceNode.attrs.jid const lid = deviceNode.attrs.lid - const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer) + const { details, hmac, accountType } = decodeAndHydrate( + proto.ADVSignedDeviceIdentityHMAC, + deviceIdentityNode.content as Buffer + ) const isHostedAccount = accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED const hmacPrefix = isHostedAccount ? Buffer.from([6, 5]) : Buffer.alloc(0) @@ -144,7 +148,7 @@ export const configureSuccessfulPairing = ( throw new Boom('Invalid account signature') } - const account = proto.ADVSignedDeviceIdentity.decode(details) + const account = decodeAndHydrate(proto.ADVSignedDeviceIdentity, details) const { accountSignatureKey, accountSignature, details: deviceDetails } = account const accountMsg = Buffer.concat([Buffer.from([6, 0]), deviceDetails, signedIdentityKey.public]) if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) { @@ -158,7 +162,7 @@ export const configureSuccessfulPairing = ( const identity = createSignalIdentity(lid!, accountSignatureKey) const accountEnc = encodeSignedDeviceIdentity(account, false) - const deviceIdentity = proto.ADVDeviceIdentity.decode(account.details) + const deviceIdentity = decodeAndHydrate(proto.ADVDeviceIdentity, account.details) const reply: BinaryNode = { tag: 'iq', diff --git a/src/WABinary/generic-utils.ts b/src/WABinary/generic-utils.ts index 2b32ca7e..780debf8 100644 --- a/src/WABinary/generic-utils.ts +++ b/src/WABinary/generic-utils.ts @@ -1,5 +1,6 @@ import { Boom } from '@hapi/boom' import { proto } from '../../WAProto/index.js' +import { decodeAndHydrate } from '../Utils/proto-utils' import { type BinaryNode } from './types' // some extra useful utilities @@ -78,7 +79,7 @@ export const getBinaryNodeMessages = ({ content }: BinaryNode) => { if (Array.isArray(content)) { for (const item of content) { if (item.tag === 'message') { - msgs.push(proto.WebMessageInfo.decode(item.content as Buffer)) + msgs.push(decodeAndHydrate(proto.WebMessageInfo, item.content as Buffer)) } } } diff --git a/src/__tests__/Utils/generics.test.ts b/src/__tests__/Utils/generics.test.ts new file mode 100644 index 00000000..2408961e --- /dev/null +++ b/src/__tests__/Utils/generics.test.ts @@ -0,0 +1,70 @@ +import { BufferJSON } from '../../Utils/generics' + +describe('BufferJSON', () => { + const originalObject = { + id: 1, + key: Buffer.from([1, 2, 3, 4, 5]), + nested: { + data: Buffer.from([6, 7, 8]) + } + } + + const legacyJsonString = '{"id":1,"key":{"0":1,"1":2,"2":3,"3":4,"4":5},"nested":{"data":{"0":6,"1":7,"2":8}}}' + + it('should correctly perform a round trip (stringify with replacer, parse with reviver)', () => { + const serialized = JSON.stringify(originalObject, BufferJSON.replacer) + + expect(serialized).toContain('"type":"Buffer"') + expect(serialized).not.toContain('"data":[1,2,3,4,5]') + + const revived = JSON.parse(serialized, BufferJSON.reviver) + + expect(Buffer.isBuffer(revived.key)).toBe(true) + expect(Buffer.isBuffer(revived.nested.data)).toBe(true) + expect(revived.key).toEqual(originalObject.key) + expect(revived).toEqual(originalObject) + }) + + it('should correctly revive a legacy JSON string with object-like buffers', () => { + const revived = JSON.parse(legacyJsonString, BufferJSON.reviver) + + expect(Buffer.isBuffer(revived.key)).toBe(true) + expect(Buffer.isBuffer(revived.nested.data)).toBe(true) + expect(revived.key).toEqual(originalObject.key) + expect(revived.nested.data).toEqual(originalObject.nested.data) + expect(revived.id).toEqual(originalObject.id) + }) + + it('should not corrupt legitimate objects that are not buffers', () => { + const legitimateProtoObject = { + '0': 'some-value', + '1': 'another-value' + } + const jsonString = JSON.stringify(legitimateProtoObject) + const revived = JSON.parse(jsonString, BufferJSON.reviver) + + expect(Buffer.isBuffer(revived)).toBe(false) + expect(revived).toEqual(legitimateProtoObject) + }) + + it('should not convert other objects or null values', () => { + const otherObject = { + a: 1, + b: 2, + c: null, + d: { '0': 1, foo: 'bar' } + } + const jsonString = JSON.stringify(otherObject) + const revived = JSON.parse(jsonString, BufferJSON.reviver) + + expect(Buffer.isBuffer(revived.a)).toBe(false) + expect(revived.c).toBeNull() + expect(Buffer.isBuffer(revived.d)).toBe(false) + expect(revived).toEqual(otherObject) + }) + + it('should correctly handle an empty object', () => { + const revived = JSON.parse('{}', BufferJSON.reviver) + expect(revived).toEqual({}) + }) +})