diff --git a/Dockerfile b/Dockerfile index 24c4e3bc7..204f3752c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ WORKDIR /evolution COPY ./package*.json ./ COPY ./tsconfig.json ./ COPY ./tsup.config.ts ./ +# npm ci's postinstall (patch-package) applies these to node_modules. +COPY ./patches ./patches RUN npm ci --silent diff --git a/FORK.md b/FORK.md index b6dc0c583..cedb1ee8c 100644 --- a/FORK.md +++ b/FORK.md @@ -32,6 +32,7 @@ A fix without a failing test first is not merged here. See `AGENTS.md`. against Evolution Foundation's server and breaks `POST /instance/create`. - Baileys pinned to `7.0.0-rc14` (2.3.7 ships rc.9, which is inside the range of CVE-2026-48063). + It runs with `patches/baileys+7.0.0-rc14.patch` applied (pairing, below). ## Changes from 2.3.7 @@ -73,6 +74,7 @@ named where one exists. - Session files on disk are replaced whole, never written in place: each signal key file of the Prisma auth store (keys live under INSTANCE_DIR next to creds in the database) and the pending-logout marker go to a temp file in the same directory, are flushed, renamed over the target and the directory flushed. A process killed mid-write, a full disk or two writes of one key at once used to leave a torn or empty file, and a key file that does not parse reads as no key; tested by SIGKILLing a writer at random moments (half the files torn or empty before, none after). A failed write keeps the previous file and rejects; writes of one file run in call order; a temp file a killed writer left is removed when the store next opens. The provider-files store writes on its own server and Redis holds no files, so neither is covered. - Profile pictures are looked up once per contact per hour, at most four at a time, and history never waits on them (#1883). The picture update a history batch sends carries each contact's newest name, and a lookup that finishes after WhatsApp said the picture changed or was removed is dropped. - One pairing code per connect attempt, and a fresh QR budget per attempt (#2100, #2696). +- A new device can be linked again. Since about 2026-07-28 WhatsApp sends a `companion_reg_refresh` notification during pairing, which retires the adv secret the QR advertises; Baileys 7.0.0-rc14 dropped it (its ack even threw before login), so the phone showed "Couldn't link device" and the QR refs ran out (Baileys #2737). Baileys now mints a new adv secret, stores it and re-renders the QR on screen with it, spending no ref, and acks the notification. Linking with a code keeps the adv secret the code exchange derives, and a `link_code_companion_reg` notification with no pairing data no longer fails with 'Invalid buffer' (Baileys #2600). No Baileys release has these fixes, so the fork carries them as `patches/baileys+7.0.0-rc14.patch`, applied by patch-package on `npm install` and `npm ci` (the Docker build included), and a test fails when the Baileys under test lacks it. The patch is the one vendored in #2727 by Clovis Coli Jr, which compiles Baileys #2765 (doryani-ai) and #2602 (joivo), plus the one-line ack fix of Baileys #2749 (IamYGT). Tested against a local WhatsApp that plays the phone's side of both flows; not yet checked against a real phone. **Live checks** - A live check against a real phone can be recorded (`LIVE_RECORD_DIR`; the QR, its image and pairing codes are never written), scrubbed into a fixture, and replayed through the real event buffer and service in a test. The scrubber keeps a value as written only under a field it lists with a value that field is known to take, and stops on a field it does not know; its leak gate searches the output for every raw value that is not structure; a guard scans every committed fixture. None of them recognises a name the scrubber mistook for structure, so a person still reads every new fixture. A replay compares the webhooks' content, not their order or the pictures, and runs on events already decoded: it shows what Evolution does with what WhatsApp sent, not the encryption or the timing around it. The protocol, the check catalogue and the results log are in `docs/LIVE-CHECKS.md`. diff --git a/package-lock.json b/package-lock.json index 506d676f9..147241130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "evolution-api", "version": "2.3.7", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@adiwajshing/keyed-db": "^0.2.4", @@ -94,6 +95,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "husky": "^9.1.7", "lint-staged": "^16.1.6", + "patch-package": "^8.0.1", "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", @@ -5746,6 +5748,13 @@ "url": "https://github.com/sponsors/eshaz" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", @@ -6698,6 +6707,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -9165,6 +9190,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/findup-sync": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", @@ -10423,6 +10458,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10766,6 +10817,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -10888,6 +10952,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10921,6 +11005,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -11018,6 +11112,16 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12779,6 +12883,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", @@ -13181,6 +13302,137 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/patch-package/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/patch-package/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/patch-package/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/patch-package/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -14838,6 +15090,16 @@ "url": "https://github.com/sponsors/eshaz" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", diff --git a/package.json b/package.json index d8cf259a1..849497d08 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "db:studio": "node runWithProvider.js \"npx prisma studio --schema ./prisma/DATABASE_PROVIDER-schema.prisma\"", "db:migrate:dev": "node runWithProvider.js \"rm -rf ./prisma/migrations && cp -r ./prisma/DATABASE_PROVIDER-migrations ./prisma/migrations && npx prisma migrate dev --schema ./prisma/DATABASE_PROVIDER-schema.prisma && cp -r ./prisma/migrations/* ./prisma/DATABASE_PROVIDER-migrations\"", "db:migrate:dev:win": "node runWithProvider.js \"xcopy /E /I prisma\\DATABASE_PROVIDER-migrations prisma\\migrations && npx prisma migrate dev --schema prisma\\DATABASE_PROVIDER-schema.prisma\"", + "postinstall": "patch-package --error-on-fail", "prepare": "husky", "test:watch": "vitest" }, @@ -154,6 +155,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "husky": "^9.1.7", "lint-staged": "^16.1.6", + "patch-package": "^8.0.1", "prettier": "^3.4.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.20.5", diff --git a/patches/baileys+7.0.0-rc14.patch b/patches/baileys+7.0.0-rc14.patch new file mode 100644 index 000000000..5fa910c76 --- /dev/null +++ b/patches/baileys+7.0.0-rc14.patch @@ -0,0 +1,229 @@ +diff --git a/node_modules/baileys/lib/Socket/messages-recv.js b/node_modules/baileys/lib/Socket/messages-recv.js +index f2003ac..9920fee 100644 +--- a/node_modules/baileys/lib/Socket/messages-recv.js ++++ b/node_modules/baileys/lib/Socket/messages-recv.js +@@ -359,7 +359,7 @@ export const makeMessagesRecvSocket = (config) => { + } + }; + const sendMessageAck = async (node, errorCode) => { +- const stanza = buildAckStanza(node, errorCode, authState.creds.me.id); ++ const stanza = buildAckStanza(node, errorCode, authState.creds.me?.id); + logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack'); + await sendNode(stanza); + }; +@@ -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 diff --git a/test/connect/companion-reg-refresh.test.ts b/test/connect/companion-reg-refresh.test.ts new file mode 100644 index 000000000..7a5f7b10e --- /dev/null +++ b/test/connect/companion-reg-refresh.test.ts @@ -0,0 +1,239 @@ +// Since about 2026-07-28 WhatsApp sends +// to a companion that is being linked (Baileys #2737; reproduced in whatsmeow). +// It retires the adv secret the companion advertises: WA Web answers it by +// minting a new adv secret and re-rendering the QR on screen with it +// (WAWebHandleCompanionReqRefreshNotification), and a phone that scans a QR +// still carrying the retired secret shows "Couldn't link device". +// +// Baileys 7.0.0-rc14 has no handler for it. The generic notification path +// tries to ack it and throws a TypeError before the ack is written (it reads +// creds.me.id, and an unlinked companion has no creds.me, Baileys #2738), and +// nothing else happens: the QR keeps the retired secret, no pair-success ever +// comes, and the refs run out ('QR refs attempts ended'). +// +// Linking with a code never puts the adv secret in a QR: both sides derive it +// from the code exchange (primary_hello, companion_finish), and the +// pair-success that follows is authenticated with it. A refresh must not +// replace that secret, or a link that was about to succeed cannot. The same +// flow also carries a link_code_companion_reg notification with no pairing data +// (Baileys #2600), which rc14 reads as a primary_hello and fails on +// ('Invalid buffer'). +// +// Evolution's real connect runs the real Baileys socket against a local +// "WhatsApp" (test/helpers/fake-whatsapp.ts), and the phone's side is played +// with the same primitives Baileys verifies with. +import { vi } from 'vitest'; + +const h = vi.hoisted(() => ({ wsUrl: '', sockets: [] as any[], services: [] as any[] })); +const tmp = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'evo-pairing-')); +}); + +vi.mock('@api/server.module', () => import('../helpers/fake-server-module')); +vi.mock('@config/path.config', async (importOriginal) => ({ ...(await importOriginal()), INSTANCE_DIR: tmp })); +vi.mock('@utils/fetchLatestWaWebVersion', () => ({ + fetchLatestWaWebVersion: async () => ({ version: [2, 3000, 1], isLatest: true }), +})); +// The real socket, sent to the local "WhatsApp" instead of web.whatsapp.com. +vi.mock('baileys', async (importOriginal) => { + const orig = await importOriginal(); + const make = (config: any) => { + const socket = orig.makeWASocket({ ...config, waWebSocketUrl: h.wsUrl }); + h.sockets.push(socket); + return socket; + }; + // connectionUpdate waits a second before asking for a pairing code; the wait is not what is tested. + return { ...orig, default: make, makeWASocket: make, delay: (ms: number) => orig.delay(Math.min(ms, 5)) }; +}); + +import { rmSync } from 'node:fs'; + +import { getBinaryNodeChild, getBinaryNodeChildBuffer } from 'baileys'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { makeService, settle } from '../helpers/baileys-service'; +import { captureOutput } from '../helpers/capture-output'; +import { emitted, prismaRepository as prisma } from '../helpers/fake-server-module'; +import { + ackFor, + bareLinkCodeNotification, + companionRegRefresh, + deliver, + iqResult, + pairDevice, + qrFields, + scanQr, + startCodeLink, + startFakeWhatsapp, +} from '../helpers/fake-whatsapp'; +import { loopbackOnly } from '../helpers/local-net'; + +const PHONE = '972500000000'; +const REFS = ['2@ref-1', '2@ref-2', '2@ref-3', '2@ref-4', '2@ref-5', '2@ref-6']; + +let guard: ReturnType; +let whatsapp: Awaited>; + +beforeAll(() => void (guard = loopbackOnly())); +beforeEach(async () => { + whatsapp = await startFakeWhatsapp(); + h.wsUrl = whatsapp.url; + emitted.length = 0; +}); +afterEach(async () => { + // Tear down without the service answering the close with a reconnect. + for (const service of h.services) { + service.connectToWhatsapp = async () => undefined; + service.connect = async () => undefined; + } + for (const s of h.sockets) await s.end(undefined).catch(() => undefined); + for (const service of h.services) await settle(service); + for (const service of h.services) service.stopReconnecting(); + await whatsapp.close(); + h.sockets.length = 0; + h.services.length = 0; + for (const t of Object.values(prisma) as any[]) if (Array.isArray(t?.rows)) t.rows.length = 0; + rmSync(`${tmp}/inst-1`, { recursive: true, force: true }); +}); +afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); + expect(guard.refused).toEqual([]); + guard.restore(); +}); + +/** A new instance, connecting through Evolution's real connect, its socket open to the local WhatsApp. */ +async function connecting(number?: string) { + await prisma.instance.create({ + data: { id: 'inst-1', name: 'test', connectionStatus: 'close', token: 'token', integration: 'WHATSAPP-BAILEYS' }, + }); + const { service } = await makeService({ prisma }); + h.services.push(service); + await service.connectToWhatsapp(number); + const socket = h.sockets.at(-1); + await vi.waitFor(() => expect(socket.ws.isOpen).toBe(true)); + return { service, socket }; +} + +/** The QR codes Evolution sent on qrcode.updated, in order. */ +const qrs = () => emitted.filter((e) => e.event === 'qrcode.updated' && e.data?.qrcode).map((e) => e.data.qrcode); +/** The creds Evolution stored for the session (Prisma auth store: JSON inside a JSON string). */ +const storedCreds = () => JSON.parse(JSON.parse(prisma.session.rows[0].creds)); +/** Everything the companion wrote to WhatsApp, as tag and attributes. */ +const wire = () => whatsapp.sent.map((n) => ({ tag: n.tag, attrs: n.attrs })); +const sentWith = (tag: string, child: string) => + whatsapp.sent.find((n) => n.tag === tag && Array.isArray(n.content) && n.content[0]?.tag === child); + +/** Baileys' error lines (pino level 50 and above) in captured output. */ +const errorLines = (out: string) => + out + .split('\n') + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l)) + .filter((l) => l.level >= 50) + .map((l) => ({ msg: l.msg, error: l.error })); + +describe('companion_reg_refresh while linking by QR', () => { + it('rotates the adv secret, re-renders the QR on screen with it, and the scan links the device', async () => { + const { service, socket } = await connecting(); + + deliver(socket, pairDevice(REFS)); + await vi.waitFor(() => expect(qrs()).toHaveLength(1)); + const first = qrFields(qrs()[0].code); + expect(first.ref).toBe('2@ref-1'); + await settle(service); + expect(storedCreds().advSecretKey).toBe(first.adv); + + // The phone scans; WhatsApp retires the secret that QR advertised. + const refresh = companionRegRefresh(); + const retired = [first.adv]; + deliver(socket, refresh); + await vi.waitFor(() => expect(qrs()).toHaveLength(2), { timeout: 3000 }); + await vi.waitFor(() => expect(wire()).toContainEqual(ackFor(refresh))); + await settle(service); + + // The same ref (a refresh spends none), with a new secret, which is the one stored. + const second = qrFields(qrs()[1].code); + expect(second).toEqual({ ...first, adv: second.adv }); + expect(second.adv).not.toBe(first.adv); + expect(Buffer.from(second.adv, 'base64')).toHaveLength(32); + expect(storedCreds().advSecretKey).toBe(second.adv); + + // The person scans the QR on screen, and WhatsApp confirms the link. + const { node, device } = scanQr(qrs().at(-1).code, retired); + deliver(socket, node); + await vi.waitFor(() => expect(sentWith('iq', 'pair-device-sign')?.attrs).toEqual({ to: '@s.whatsapp.net', type: 'result', id: node.attrs.id })); + await settle(service); + expect(storedCreds().me).toEqual({ id: device.jid, lid: device.lid }); + expect(storedCreds().advSecretKey).toBe(second.adv); + }); +}); + +describe('companion_reg_refresh while linking with a code', () => { + it('keeps the secret the code exchange derived, and the link completes', async () => { + let service: any; + let socket: any; + const out = await captureOutput(async () => { + ({ service, socket } = await connecting(PHONE)); + deliver(socket, pairDevice(REFS)); + await vi.waitFor(() => expect(qrs()).toHaveLength(1)); + const code = qrs()[0].pairingCode; + expect(code).toMatch(/^[A-Z0-9]{8}$/); + + // Stage 1: the companion_hello, which WhatsApp answers with the ref of this code. + const hello = sentWith('iq', 'link_code_companion_reg'); + expect(getBinaryNodeChild(hello, 'link_code_companion_reg').attrs).toEqual({ + jid: `${PHONE}@s.whatsapp.net`, + stage: 'companion_hello', + should_show_push_notification: 'true', + }); + const ref = Buffer.from('link-code-ref-1'); + deliver( + socket, + iqResult(hello.attrs.id, [ + { tag: 'link_code_companion_reg', attrs: { stage: 'companion_hello' }, content: [{ tag: 'link_code_pairing_ref', attrs: {}, content: ref }] }, + ]), + ); + + // The person opens Linked devices: a link_code_companion_reg with no pairing data. + const bare = bareLinkCodeNotification(); + deliver(socket, bare); + await vi.waitFor(() => expect(wire()).toContainEqual(ackFor(bare))); + + // They type the code; WhatsApp refreshes the registration, and the phone says hello. + const refreshAfterCode = companionRegRefresh(); + deliver(socket, refreshAfterCode); + await vi.waitFor(() => expect(wire()).toContainEqual(ackFor(refreshAfterCode))); + const link = await startCodeLink(hello, code, ref); + deliver(socket, link.primaryHello); + + // Stage 2: the companion_finish, which derives the adv secret the pair-success is authenticated with. + const linkCodeIqs = () => whatsapp.sent.filter((n) => n.tag === 'iq' && getBinaryNodeChild(n, 'link_code_companion_reg')); + await vi.waitFor(() => expect(linkCodeIqs()).toHaveLength(2), { timeout: 5000 }); + const finish = linkCodeIqs()[1]; + expect(getBinaryNodeChild(finish, 'link_code_companion_reg').attrs).toEqual({ jid: `${PHONE}@s.whatsapp.net`, stage: 'companion_finish' }); + expect(getBinaryNodeChildBuffer(getBinaryNodeChild(finish, 'link_code_companion_reg'), 'link_code_pairing_ref')).toEqual(ref); + const { advSecret, node, device } = link.finish(finish); + deliver(socket, iqResult(finish.attrs.id)); + await settle(service); + await vi.waitFor(() => expect(storedCreds().advSecretKey).toBe(advSecret)); + + // A refresh while the pair-success is on its way must leave that secret alone. + const refreshPending = companionRegRefresh(); + deliver(socket, refreshPending); + await vi.waitFor(() => expect(wire()).toContainEqual(ackFor(refreshPending))); + await settle(service); + expect(storedCreds().advSecretKey).toBe(advSecret); + + deliver(socket, node); + await vi.waitFor(() => expect(sentWith('iq', 'pair-device-sign')?.attrs).toEqual({ to: '@s.whatsapp.net', type: 'result', id: node.attrs.id })); + await settle(service); + expect(storedCreds().me).toEqual({ id: device.jid, lid: device.lid }); + }); + + // Nothing in the flow failed on the way. + expect(errorLines(out)).toEqual([]); + }); +}); diff --git a/test/harness/baileys-patch.test.ts b/test/harness/baileys-patch.test.ts new file mode 100644 index 000000000..db761cd91 --- /dev/null +++ b/test/harness/baileys-patch.test.ts @@ -0,0 +1,65 @@ +// The Baileys this fork runs is the pinned release plus the patches in +// patches/, applied by patch-package on npm's postinstall (npm ci in the Docker +// builder included). A patch is named for the version it was written against, +// so a Baileys bump that forgets it, or an install that skipped it, fails here +// instead of shipping a Baileys that cannot link a device. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const root = join(__dirname, '../..'); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const pinned = pkg.dependencies.baileys; +const resolved = process.env.BAILEYS_RESOLVED_DIR!; + +/** Each hunk of a unified diff, as the text of its new side, per file (relative to the package). */ +function hunks(patch: string) { + const out: { file: string; text: string }[] = []; + let file = ''; + let lines: string[] | undefined; + const flush = () => lines && out.push({ file, text: lines.join('\n') }); + for (const line of patch.split('\n')) { + if (line.startsWith('diff --git ')) { + flush(); + lines = undefined; + } else if (line.startsWith('+++ ')) { + file = line.replace(/^\+\+\+ b\/node_modules\/baileys\//, ''); + } else if (line.startsWith('@@')) { + flush(); + lines = []; + } else if (lines && (line.startsWith(' ') || line.startsWith('+'))) { + lines.push(line.slice(1)); + } + } + flush(); + return out; +} + +describe('harness: the pinned Baileys carries its patch', () => { + const patchFile = join(root, 'patches', `baileys+${pinned}.patch`); + + it('patches/ holds a patch for the pinned version, and none for another', () => { + expect(existsSync(patchFile)).toBe(true); + expect(readdirSync(join(root, 'patches')).filter((f) => f.startsWith('baileys+'))).toEqual([`baileys+${pinned}.patch`]); + }); + + it.runIf(!process.env.BAILEYS_DIR)('every hunk of it is in the Baileys under test', () => { + const missing = hunks(readFileSync(patchFile, 'utf8')).filter( + ({ file, text }) => !readFileSync(join(resolved, file), 'utf8').includes(text), + ); + expect(missing.map((h) => h.file)).toEqual([]); + }); + + it('npm applies it on install, including the Docker build', () => { + expect(pkg.scripts.postinstall).toBe('patch-package --error-on-fail'); + // The builder runs npm ci with dev dependencies, so patch-package is there when postinstall runs. + expect(pkg.devDependencies['patch-package']).toBeDefined(); + const docker = readFileSync(join(root, 'Dockerfile'), 'utf8').split('\n'); + const copy = docker.findIndex((l) => /^COPY \.\/patches \.\/patches\s*$/.test(l)); + const ci = docker.findIndex((l) => /^RUN npm ci\b/.test(l)); + expect(copy).toBeGreaterThan(-1); + expect(ci).toBeGreaterThan(copy); + expect(docker[ci]).not.toMatch(/--ignore-scripts|--omit[= ]dev|--production/); + }); +}); diff --git a/test/helpers/fake-whatsapp.ts b/test/helpers/fake-whatsapp.ts new file mode 100644 index 000000000..a2f3a03ec --- /dev/null +++ b/test/helpers/fake-whatsapp.ts @@ -0,0 +1,235 @@ +// A local "WhatsApp" for the pairing stage, and the phone on the other side of it. +// +// The companion is the real Baileys socket, pointed at a WebSocket server on +// 127.0.0.1 that accepts the upgrade and never answers the Noise handshake. +// Until that handshake finishes Baileys writes its frames unencrypted, so the +// server reads every stanza the companion sends (`sent`). What WhatsApp sends +// is handed to the socket with `deliver()`, the dispatch Baileys' own +// onMessageReceived performs once a frame is decrypted (rc14 lib/Socket/socket.js): +// the one step skipped is the decryption itself, which needs WhatsApp's +// certificate. Stanzas are written by hand: Baileys has no builder for the +// server's side. +// +// The phone's side (`scanQr`, `startCodeLink`) uses the same primitives Baileys +// verifies with, so a pair-success is accepted only when it was built on the +// adv secret the companion holds. +import { randomBytes } from 'node:crypto'; +import net from 'node:net'; + +import { + aesDecryptCTR, + aesDecryptGCM, + aesEncryptCTR, + type BinaryNode, + Curve, + DEF_CALLBACK_PREFIX, + DEF_TAG_PREFIX, + decodeBinaryNode, + derivePairingCodeKey, + getBinaryNodeChild, + getBinaryNodeChildBuffer, + hkdf, + hmacSign, + proto, + S_WHATSAPP_NET, + WA_ADV_ACCOUNT_SIG_PREFIX, +} from 'baileys'; +import { WebSocketServer } from 'ws'; + +export async function startFakeWhatsapp() { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await new Promise((r) => server.once('listening', () => r())); + /** Every stanza a companion wrote, decoded, in order. The ClientHello (not a stanza) is left out. */ + const sent: BinaryNode[] = []; + server.on('connection', (ws) => { + let first = true; + ws.on('message', async (data: Buffer) => { + // The first frame is the intro header and the ClientHello; each later one is a 3-byte length and a stanza. + if (first) return void (first = false); + sent.push(await decodeBinaryNode(data.subarray(3))); + }); + }); + return { + url: `ws://127.0.0.1:${(server.address() as net.AddressInfo).port}/ws/chat`, + sent, + close: () => new Promise((r) => (server.clients.forEach((c) => c.terminate()), server.close(() => r()))), + }; +} + +/** + * Hand a stanza from WhatsApp to the socket, as Baileys' onMessageReceived dispatches a decrypted frame. + * Its raw 'frame' event is left out: only the handshake listens to it (awaitNextMessage), and this + * socket's handshake is still waiting for a server hello that never comes. + */ +export function deliver(sock: any, frame: BinaryNode) { + const ws = sock.ws; + ws.emit(`${DEF_TAG_PREFIX}${frame.attrs.id}`, frame); + const l0 = frame.tag; + const l1 = frame.attrs || {}; + const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''; + for (const key of Object.keys(l1)) { + ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame); + ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame); + ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame); + } + ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame); + ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame); +} + +let seq = 1000; +const stanzaId = () => String(++seq); +const now = () => String(Math.floor(Date.now() / 1000)); + +/** WhatsApp's pair-device IQ: the refs a QR may advertise, one after another. */ +export const pairDevice = (refs: string[]): BinaryNode => ({ + tag: 'iq', + attrs: { from: S_WHATSAPP_NET, type: 'set', id: stanzaId(), xmlns: 'md' }, + content: [{ tag: 'pair-device', attrs: {}, content: refs.map((ref) => ({ tag: 'ref', attrs: {}, content: Buffer.from(ref) })) }], +}); + +/** The notification WhatsApp sends during pairing since 2026-07-28, as captured in Baileys #2737. */ +export const companionRegRefresh = (): BinaryNode => ({ + tag: 'notification', + attrs: { from: S_WHATSAPP_NET, type: 'companion_reg_refresh', id: stanzaId(), t: now() }, + content: [{ tag: 'companion_reg_refresh', attrs: {} }], +}); + +/** A link_code_companion_reg notification with no pairing data, as captured in Baileys #2600. */ +export const bareLinkCodeNotification = (): BinaryNode => ({ + tag: 'notification', + attrs: { from: S_WHATSAPP_NET, type: 'link_code_companion_reg', id: stanzaId(), t: now() }, +}); + +/** WhatsApp's answer to an IQ the companion sent. */ +export const iqResult = (id: string, content?: BinaryNode[]): BinaryNode => ({ + tag: 'iq', + attrs: { from: S_WHATSAPP_NET, type: 'result', id }, + ...(content ? { content } : {}), +}); + +/** The ack Baileys owes for a stanza, as WA Web builds it. */ +export const ackFor = (node: BinaryNode) => ({ + tag: 'ack', + attrs: { id: node.attrs.id, to: node.attrs.from, class: node.tag, type: node.attrs.type }, +}); + +/** The fields of a pairing QR: https://wa.me/settings/linked_devices#ref,noise,identity,adv,platform */ +export function qrFields(code: string) { + const [ref, noise, identity, adv, platform] = code.slice(code.indexOf('#') + 1).split(','); + return { ref, noise, identity, adv, platform }; +} + +/** + * The pair-success the primary sends once it has verified the link: the account + * signs the new device's identity, and the whole is authenticated with the adv + * secret, which only the two sides of the link know. + */ +function pairSuccess(opts: { advSecret: string; companionIdentity: Buffer; account: { public: Buffer; private: Buffer } }) { + const device = { jid: '972500000000:7@s.whatsapp.net', lid: '999999999999999:7@lid' }; + const deviceDetails = Buffer.from( + proto.ADVDeviceIdentity.encode({ rawId: 7, timestamp: Math.floor(Date.now() / 1000), keyIndex: 3 }).finish(), + ); + const accountSignature = Curve.sign( + opts.account.private, + Buffer.concat([WA_ADV_ACCOUNT_SIG_PREFIX, deviceDetails, opts.companionIdentity]), + ); + const details = Buffer.from( + proto.ADVSignedDeviceIdentity.encode({ + details: deviceDetails, + accountSignatureKey: opts.account.public, + accountSignature, + }).finish(), + ); + const hmac = hmacSign(details, Buffer.from(opts.advSecret, 'base64')); + const node: BinaryNode = { + tag: 'iq', + attrs: { from: S_WHATSAPP_NET, type: 'set', id: stanzaId(), xmlns: 'md' }, + content: [ + { + tag: 'pair-success', + attrs: {}, + content: [ + { + tag: 'device-identity', + attrs: {}, + content: Buffer.from(proto.ADVSignedDeviceIdentityHMAC.encode({ details, hmac }).finish()), + }, + { tag: 'platform', attrs: { name: 'android' } }, + { tag: 'device', attrs: device }, + ], + }, + ], + }; + return { node, device }; +} + +/** + * The phone scans a QR. WhatsApp has retired every adv secret in `retired` + * (the ones a companion_reg_refresh asked the companion to drop): a QR that + * advertises one of them fails on the phone ("Couldn't link device"), and no + * pair-success is ever sent. + */ +export function scanQr(code: string, retired: string[]) { + const qr = qrFields(code); + if (retired.includes(qr.adv)) throw new Error("phone: Couldn't link device (the QR advertises a retired adv secret)"); + return pairSuccess({ + advSecret: qr.adv, + companionIdentity: Buffer.from(qr.identity, 'base64'), + account: Curve.generateKeyPair(), + }); +} + +/** + * The phone's half of linking with a code (WA Web's alt device linking): the + * person types `code`; the primary answers the companion_hello with a + * primary_hello; after the companion's companion_finish both sides derive the + * same adv secret, and the pair-success is authenticated with it. + */ +export async function startCodeLink(companionHello: BinaryNode, code: string, ref: Buffer) { + const reg = getBinaryNodeChild(companionHello, 'link_code_companion_reg')!; + const wrapped = getBinaryNodeChildBuffer(reg, 'link_code_pairing_wrapped_companion_ephemeral_pub')!; + const companionEphemeral = aesDecryptCTR( + wrapped.subarray(48, 80), + await derivePairingCodeKey(code, wrapped.subarray(0, 32)), + wrapped.subarray(32, 48), + ); + const ephemeral = Curve.generateKeyPair(); + const identity = Curve.generateKeyPair(); + const salt = randomBytes(32); + const iv = randomBytes(16); + const wrappedPrimary = Buffer.concat([salt, iv, aesEncryptCTR(ephemeral.public, await derivePairingCodeKey(code, salt), iv)]); + const primaryHello: BinaryNode = { + tag: 'notification', + attrs: { from: S_WHATSAPP_NET, type: 'link_code_companion_reg', id: stanzaId(), t: now() }, + content: [ + { + tag: 'link_code_companion_reg', + attrs: { stage: 'primary_hello' }, + content: [ + { tag: 'link_code_pairing_ref', attrs: {}, content: ref }, + { tag: 'primary_identity_pub', attrs: {}, content: identity.public }, + { tag: 'link_code_pairing_wrapped_primary_ephemeral_pub', attrs: {}, content: wrappedPrimary }, + ], + }, + ], + }; + /** Read the companion_finish, derive the adv secret as the companion did, and answer with a pair-success. */ + const finish = (companionFinish: BinaryNode) => { + const reg = getBinaryNodeChild(companionFinish, 'link_code_companion_reg')!; + const bundle = getBinaryNodeChildBuffer(reg, 'link_code_pairing_wrapped_key_bundle')!; + const companionShared = Curve.sharedKey(ephemeral.private, companionEphemeral); + const key = hkdf(companionShared, 32, { + salt: bundle.subarray(0, 32), + info: 'link_code_pairing_key_bundle_encryption_key', + }); + const plain = aesDecryptGCM(bundle.subarray(44), Buffer.from(key), bundle.subarray(32, 44), Buffer.alloc(0)); + const companionIdentity = plain.subarray(0, 32); + const random = plain.subarray(64, 96); + const identityShared = Curve.sharedKey(identity.private, companionIdentity); + const advSecret = Buffer.from( + hkdf(Buffer.concat([companionShared, identityShared, random]), 32, { info: 'adv_secret' }), + ).toString('base64'); + return { advSecret, ...pairSuccess({ advSecret, companionIdentity, account: identity }) }; + }; + return { primaryHello, finish }; +}