From dffeac4fe2bf67855deb4881a4a492588f2b70d7 Mon Sep 17 00:00:00 2001 From: Clovis Coli Jr Date: Thu, 10 Sep 2026 16:41:16 -0300 Subject: [PATCH] fix(deps): patch baileys to restore WhatsApp device pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp added a companion_reg_refresh stage to device registration (~2026-07-28). Baileys acks and discards it, so pair-success is never emitted and linking a new device is impossible. The handler is absent from rc13, rc14 and master, so no published release fixes it (WhiskeySockets/Baileys#2737, reproduced independently in whatsmeow). Uses the existing patches/ mechanism to vendor two upstream fixes until they are merged: - Baileys#2765: handle companion_reg_refresh (rotate adv secret and re-render the QR without consuming a ref) — restores pairing - Baileys#2602: guard link_code_companion_reg against the notification shape that lacks the crypto fields (Invalid buffer / 400) Also bumps baileys rc13 -> rc14 (latest published), which the patch targets. Verified on a real deployment: pairing works again and inbound messages flow end-to-end. --- package-lock.json | 8 +- package.json | 2 +- patches/baileys+7.0.0-rc14.patch | 220 +++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 patches/baileys+7.0.0-rc14.patch diff --git a/package-lock.json b/package-lock.json index 55bf83fe04..82e2b2637d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "amqplib": "^2.0.1", "audio-decode": "^2.2.3", "axios": "^1.17.0", - "baileys": "7.0.0-rc13", + "baileys": "7.0.0-rc14", "class-validator": "^0.15.1", "compression": "^1.7.5", "cors": "^2.8.6", @@ -5310,9 +5310,9 @@ "license": "MIT" }, "node_modules/baileys": { - "version": "7.0.0-rc13", - "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc13.tgz", - "integrity": "sha512-v8k74K8B5R7WNYGa26MyJAYEu3Wc4BSuK01QaK8lr30lhE8Nga31nWNu8KN0NDDt+Fsvkq4SQFFI8Q13ghjKmA==", + "version": "7.0.0-rc14", + "resolved": "https://registry.npmjs.org/baileys/-/baileys-7.0.0-rc14.tgz", + "integrity": "sha512-pewtrljhWx5JTUBvvkXZz1fL3JPiwzjBsnhx/DWf2LWBx1cZNH7J/sF22xDehba5mySWUQU4a1Pwt7lWARUxaA==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4a5cade827..2a59637bb0 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "amqplib": "^2.0.1", "audio-decode": "^2.2.3", "axios": "^1.17.0", - "baileys": "7.0.0-rc13", + "baileys": "7.0.0-rc14", "class-validator": "^0.15.1", "compression": "^1.7.5", "cors": "^2.8.6", diff --git a/patches/baileys+7.0.0-rc14.patch b/patches/baileys+7.0.0-rc14.patch new file mode 100644 index 0000000000..fe1884e625 --- /dev/null +++ b/patches/baileys+7.0.0-rc14.patch @@ -0,0 +1,220 @@ +diff --git a/node_modules/baileys/lib/Socket/messages-recv.js b/node_modules/baileys/lib/Socket/messages-recv.js +index f2003ac..1f72102 100644 +--- a/node_modules/baileys/lib/Socket/messages-recv.js ++++ b/node_modules/baileys/lib/Socket/messages-recv.js +@@ -880,6 +880,7 @@ export const makeMessagesRecvSocket = (config) => { + break; + case 'link_code_companion_reg': + const linkCodeCompanionReg = getBinaryNodeChild(node, 'link_code_companion_reg'); ++ if (!getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub')) { break; } + const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_ref')); + const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub')); + const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_wrapped_primary_ephemeral_pub')); +diff --git a/node_modules/baileys/lib/Socket/socket.js b/node_modules/baileys/lib/Socket/socket.js +index ae34a8f..944188d 100644 +--- a/node_modules/baileys/lib/Socket/socket.js ++++ b/node_modules/baileys/lib/Socket/socket.js +@@ -6,7 +6,7 @@ import { proto } from '../../WAProto/index.js'; + import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, NOISE_WA_HEADER, PROCESSABLE_HISTORY_TYPES, TimeMs, UPLOAD_TIMEOUT } from '../Defaults/index.js'; + import { QueryIds, ReachoutTimelockEnforcementType } from '../Types/index.js'; + import { DisconnectReason, XWAPaths } from '../Types/index.js'; +-import { addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, buildPairingQRData, bytesToCrockford, configureSuccessfulPairing, Curve, derivePairingCodeKey, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getCompanionPlatformId, getErrorCodeFromStreamError, getNextPreKeysNode, makeEventBuffer, makeNoiseHandler, promiseTimeout, signedKeyPair, xmppSignedPreKey } from '../Utils/index.js'; ++import { addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, buildPairingQRData, bytesToCrockford, configureSuccessfulPairing, Curve, derivePairingCodeKey, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getCompanionPlatformId, getErrorCodeFromStreamError, getNextPreKeysNode, handleCompanionRegRefresh, makeEventBuffer, makeNoiseHandler, makePairingQRRenderer, promiseTimeout, signedKeyPair, xmppSignedPreKey } from '../Utils/index.js'; + import { assertNodeErrorFree, binaryNodeToString, encodeBinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidDecode, jidEncode, S_WHATSAPP_NET } from '../WABinary/index.js'; + import { BinaryInfo } from '../WAM/BinaryInfo.js'; + import { USyncQuery, USyncUser } from '../WAUSync/index.js'; +@@ -690,6 +690,9 @@ export const makeSocket = (config) => { + ws.on('close', () => void end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed }))); + // the server terminated the connection + ws.on('CB:xmlstreamend', () => void end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))); ++ // Re-render the QR currently on screen. Set while a pairing QR flow is ++ // live on this connection, undefined otherwise. ++ let refreshPairingQR; + // QR gen + ws.on('CB:iq,type:set,pair-device', async (stanza) => { + const iq = { +@@ -705,25 +708,40 @@ export const makeSocket = (config) => { + const refNodes = getBinaryNodeChildren(pairDeviceNode, 'ref'); + const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64'); + const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64'); +- const advB64 = creds.advSecretKey; ++ const renderer = makePairingQRRenderer(refNodes.map(refNode => refNode.content.toString('utf-8')), ++ // creds.advSecretKey is read per render rather than captured once: ++ // a companion_reg_refresh rotates it mid-flow. ++ ref => ev.emit('connection.update', { ++ qr: buildPairingQRData(ref, noiseKeyB64, identityKeyB64, creds.advSecretKey, browser) ++ })); ++ refreshPairingQR = () => void renderer.refresh(); + let qrMs = qrTimeout || 60000; // time to let a QR live + const genPairQR = () => { + if (!ws.isOpen) { + return; + } +- const refNode = refNodes.shift(); +- if (!refNode) { ++ if (!renderer.next()) { + void end(new Boom('QR refs attempts ended', { statusCode: DisconnectReason.timedOut })); + return; + } +- const ref = refNode.content.toString('utf-8'); +- const qr = buildPairingQRData(ref, noiseKeyB64, identityKeyB64, advB64, browser); +- ev.emit('connection.update', { qr }); + qrTimer = setTimeout(genPairQR, qrMs); + qrMs = qrTimeout || 20000; // shorter subsequent qrs + }; + genPairQR(); + }); ++ // the server retiring an unpaired companion's registration material ++ ws.on('CB:notification,type:companion_reg_refresh', (node) => { ++ handleCompanionRegRefresh(node, { ++ creds, ++ emitCredsUpdate: update => ev.emit('creds.update', update), ++ // Deliberately re-renders the ref already on screen and leaves ++ // qrTimer alone: that ref has not expired, only the secret it ++ // advertises changed. Spending a ref here would drain the pool the ++ // server allotted and end the flow with 'QR refs attempts ended'. ++ refreshQR: () => refreshPairingQR?.(), ++ logger ++ }); ++ }); + // device paired for the first time + // if device pairs successfully, the server asks to restart the connection + ws.on('CB:iq,,pair-success', async (stanza) => { +diff --git a/node_modules/baileys/lib/Utils/companion-reg-client-utils.d.ts b/node_modules/baileys/lib/Utils/companion-reg-client-utils.d.ts +index a79a52c..3bc161c 100644 +--- a/node_modules/baileys/lib/Utils/companion-reg-client-utils.d.ts ++++ b/node_modules/baileys/lib/Utils/companion-reg-client-utils.d.ts +@@ -1,4 +1,6 @@ +-import type { WABrowserDescription } from '../Types/index.js'; ++import type { AuthenticationCreds, WABrowserDescription } from '../Types/index.js'; ++import type { BinaryNode } from '../WABinary/index.js'; ++import type { ILogger } from './logger.js'; + export declare enum CompanionWebClientType { + UNKNOWN = 0, + CHROME = 1, +@@ -14,4 +16,40 @@ export declare enum CompanionWebClientType { + export declare const getCompanionWebClientType: ([os, browserName]: WABrowserDescription) => CompanionWebClientType; + export declare const getCompanionPlatformId: (browser: WABrowserDescription) => string; + export declare const buildPairingQRData: (ref: string, noiseKeyB64: string, identityKeyB64: string, advB64: string, browser: WABrowserDescription) => string; ++export type PairingQRRenderer = { ++ /** Render the next ref's QR. False once the server's allotment is spent. */ ++ next(): boolean; ++ /** Re-render the QR on screen. Consumes no ref; false if none is shown yet. */ ++ refresh(): boolean; ++}; ++/** ++ * Holds the ref currently on screen so it can be re-rendered. ++ * ++ * `render` is called with the ref rather than a finished payload so the caller ++ * can read the adv secret at render time: a `companion_reg_refresh` rotates it ++ * mid-flow, and every QR emitted afterwards has to advertise the new value. ++ */ ++export declare const makePairingQRRenderer: (refs: string[], render: (ref: string) => void) => PairingQRRenderer; ++export type CompanionRegRefreshContext = { ++ creds: AuthenticationCreds; ++ emitCredsUpdate: (update: Partial) => void; ++ refreshQR: () => void; ++ logger: ILogger; ++}; ++export type CompanionRegRefreshOutcome = 'rotated' | 'ignored_malformed' | 'ignored_registered'; ++/** ++ * `` - the server retiring an ++ * unpaired companion's registration material. ++ * ++ * WA Web accepts the stanza with either a `companion_reg_refresh` or a ++ * `pair-device-rotate-qr` child, rejects it when neither is present, and ++ * answers by regenerating the adv secret key. That key is a quarter of what ++ * the pairing QR advertises, so a client that only acks keeps offering a QR ++ * built on a secret the server has already retired: the phone scans it, ++ * reports a failed link, and no pair-success ever arrives. ++ * ++ * The ack itself is unchanged - the generic notification path already sends ++ * it - so this only adds the rotation and the re-render. ++ */ ++export declare const handleCompanionRegRefresh: (node: BinaryNode, { creds, emitCredsUpdate, refreshQR, logger }: CompanionRegRefreshContext) => CompanionRegRefreshOutcome; + //# sourceMappingURL=companion-reg-client-utils.d.ts.map +\ No newline at end of file +diff --git a/node_modules/baileys/lib/Utils/companion-reg-client-utils.js b/node_modules/baileys/lib/Utils/companion-reg-client-utils.js +index f5edebd..5021c8f 100644 +--- a/node_modules/baileys/lib/Utils/companion-reg-client-utils.js ++++ b/node_modules/baileys/lib/Utils/companion-reg-client-utils.js +@@ -1,3 +1,5 @@ ++import { randomBytes } from 'crypto'; ++import { getBinaryNodeChild } from '../WABinary/index.js'; + export var CompanionWebClientType; + (function (CompanionWebClientType) { + CompanionWebClientType[CompanionWebClientType["UNKNOWN"] = 0] = "UNKNOWN"; +@@ -32,4 +34,72 @@ export const buildPairingQRData = (ref, noiseKeyB64, identityKeyB64, advB64, bro + return ('https://wa.me/settings/linked_devices#' + + [ref, noiseKeyB64, identityKeyB64, advB64, getCompanionPlatformId(browser)].join(',')); + }; ++/** ++ * Holds the ref currently on screen so it can be re-rendered. ++ * ++ * `render` is called with the ref rather than a finished payload so the caller ++ * can read the adv secret at render time: a `companion_reg_refresh` rotates it ++ * mid-flow, and every QR emitted afterwards has to advertise the new value. ++ */ ++export const makePairingQRRenderer = (refs, render) => { ++ let index = 0; ++ let current; ++ return { ++ next() { ++ const ref = refs[index]; ++ if (ref === undefined) { ++ return false; ++ } ++ index += 1; ++ current = ref; ++ render(ref); ++ return true; ++ }, ++ refresh() { ++ if (current === undefined) { ++ return false; ++ } ++ render(current); ++ return true; ++ } ++ }; ++}; ++/** The two children WA Web's parser accepts on this notification. */ ++const COMPANION_REG_REFRESH_CHILDREN = ['companion_reg_refresh', 'pair-device-rotate-qr']; ++/** ++ * `` - the server retiring an ++ * unpaired companion's registration material. ++ * ++ * WA Web accepts the stanza with either a `companion_reg_refresh` or a ++ * `pair-device-rotate-qr` child, rejects it when neither is present, and ++ * answers by regenerating the adv secret key. That key is a quarter of what ++ * the pairing QR advertises, so a client that only acks keeps offering a QR ++ * built on a secret the server has already retired: the phone scans it, ++ * reports a failed link, and no pair-success ever arrives. ++ * ++ * The ack itself is unchanged - the generic notification path already sends ++ * it - so this only adds the rotation and the re-render. ++ */ ++export const handleCompanionRegRefresh = (node, { creds, emitCredsUpdate, refreshQR, logger }) => { ++ if (!COMPANION_REG_REFRESH_CHILDREN.some(tag => getBinaryNodeChild(node, tag))) { ++ logger.warn({ node }, 'companion_reg_refresh carries neither expected child; ignoring'); ++ return 'ignored_malformed'; ++ } ++ // WA Web rotates unconditionally; a registered session is the one case ++ // where that is wrong here. `creds.me` is set by pair-success and by ++ // requestPairingCode, and in both cases the adv secret is what a completed ++ // or pending pairing is verified against - re-minting it would break the ++ // session rather than refresh a pending registration. ++ if (creds.me) { ++ logger.debug({ id: node.attrs.id }, 'companion_reg_refresh on a registered session; keeping the adv secret'); ++ return 'ignored_registered'; ++ } ++ // Same construction as initAuthCreds and as WA Web's generateADVSecretKey: ++ // 32 CSPRNG bytes, base64. ++ creds.advSecretKey = randomBytes(32).toString('base64'); ++ emitCredsUpdate({ advSecretKey: creds.advSecretKey }); ++ logger.info({ id: node.attrs.id }, 'rotated the adv secret the server asked to retire; re-rendering the pairing QR'); ++ refreshQR(); ++ return 'rotated'; ++}; + //# sourceMappingURL=companion-reg-client-utils.js.map +\ No newline at end of file