whalibmob 5.33.26 → 5.33.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const https = require('https');
5
5
  const tls = require('tls');
6
+ const zlib = require('zlib');
6
7
  const fs = require('fs');
7
8
  const os = require('os');
8
9
  const path = require('path');
@@ -40,13 +41,18 @@ function _uaPlatformName(device) {
40
41
 
41
42
  function registrationHeaders(device, waVersion) {
42
43
  if (device.os === 'android') {
44
+ // Header set and order mirror the native client's registration POST: no
45
+ // Accept, Connection kept alive, gzip advertised (the response is gunzipped
46
+ // on both the direct and SOCKS paths). request_token is an UPPERCASE UUID —
47
+ // the native client never mints a lowercase one.
43
48
  return {
44
- 'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
45
- 'Content-Type': 'application/x-www-form-urlencoded',
46
- 'Accept': 'text/json',
47
- 'WaMsysRequest': '1',
49
+ 'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
50
+ 'WaMsysRequest': '1',
48
51
  // A fresh one per request, the way the native client mints it.
49
- 'request_token': uuidv4()
52
+ 'request_token': uuidv4().toUpperCase(),
53
+ 'Content-Type': 'application/x-www-form-urlencoded',
54
+ 'Connection': 'Keep-Alive',
55
+ 'Accept-Encoding': 'gzip'
50
56
  };
51
57
  }
52
58
  return {
@@ -66,7 +72,7 @@ const {
66
72
  parseSocksProxy, socksProxyUrl, socksConnect, proxyAgent
67
73
  } = require('./socks');
68
74
 
69
- async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
75
+ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device, query) {
70
76
  const dHost = 'v.whatsapp.net';
71
77
  const dPort = 443;
72
78
 
@@ -88,23 +94,27 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, dev
88
94
  });
89
95
 
90
96
  const headers = registrationHeaders(device || getDeviceConfig(), waVersion);
97
+ // The native client submits the parameters twice: once in the clear as the
98
+ // URL query, once encrypted inside ENC. The query string here is the exact
99
+ // plaintext that was encrypted, byte for byte.
100
+ const target = query ? `/v2${path}?${query}` : `/v2${path}`;
91
101
  const reqLines = [
92
- `POST /v2${path} HTTP/1.1`,
102
+ `POST ${target} HTTP/1.1`,
93
103
  `Host: ${dHost}`,
94
104
  ...Object.keys(headers).map(k => `${k}: ${headers[k]}`),
95
105
  `Content-Length: ${Buffer.byteLength(body)}`
96
106
  ];
97
107
  if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
98
- // Asked for, but not relied on: the server answers and then keeps the
99
- // connection open anyway. The response is finished by its own length, not by
100
- // the socket closing.
101
- reqLines.push(`Connection: close`, '', body);
108
+ // Connection: Keep-Alive rides in from registrationHeaders, matching the
109
+ // native client; the response is still delimited by its own length and the
110
+ // socket is destroyed once it is read.
111
+ reqLines.push('', body);
102
112
  const req = reqLines.join('\r\n');
103
113
 
104
114
  tlsSocket.write(req);
105
115
 
106
- const { status, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
107
- const bodyStr = bodyBuf.toString('utf8');
116
+ const { status, headers: resHeaders, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
117
+ const bodyStr = decodeBody(bodyBuf, resHeaders).toString('utf8');
108
118
 
109
119
  if (status !== 200) throw new Error(`HTTP ${status} ${path}: ${bodyStr}`);
110
120
  try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
@@ -172,6 +182,17 @@ function readHttpResponse(socket, label, timeoutMs) {
172
182
  });
173
183
  }
174
184
 
185
+ // The registration POST advertises `Accept-Encoding: gzip`, so the server may
186
+ // answer gzip-compressed. Decode by the response's own content-encoding; fall
187
+ // back to the raw bytes if the header lies or the stream will not inflate, so a
188
+ // plain reply still parses. `headers` is the raw header block (any case).
189
+ function decodeBody(bodyBuf, headers) {
190
+ if (/^content-encoding:[ \t]*gzip/im.test(headers || '')) {
191
+ try { return zlib.gunzipSync(bodyBuf); } catch (_) { /* fall through */ }
192
+ }
193
+ return bodyBuf;
194
+ }
195
+
175
196
  /**
176
197
  * Parse what has arrived so far.
177
198
  *
@@ -1221,6 +1242,61 @@ function toUrlHex(buf) {
1221
1242
  // IMPORTANT: do NOT use URLSearchParams here — it would double-encode the
1222
1243
  // percent-hex sequences in the 'id' field (%AA → %25AA).
1223
1244
 
1245
+ // The native client inserts registration fields into a LinkedHashMap in a
1246
+ // fixed order; the wire sequence is that insertion order, not a sort. These are
1247
+ // the live orders captured from WhatsApp Android (WA_REVERSE_2026): 50 fields
1248
+ // for /v2/code, 49 for /v2/register. gpia/token/vname that this build omits
1249
+ // simply drop out — their slots collapse.
1250
+ const CODE_PARAM_ORDER = [
1251
+ 'method', 'backup_token', '_gs', 'sim_mnc', 'id', 'mnc', '_gg',
1252
+ 'network_radio_type', 'lg', 'rc', 'pid', '_ge', 'cellular_strength', 'gpia',
1253
+ 'hasinrc', 'aid', 'mcc', '_gp', 'hasav', 'device_ram', 'db', 'sim_type',
1254
+ 'prefer_sms_over_flash', 'access_session_id', 'sim_mcc', 'roaming_type',
1255
+ 'mistyped', 'airplane_mode_type', 'e_ident', 'e_skey_sig', 'entrypoint',
1256
+ 'token', 'in', 'expid', 'simnum', 'cc', 'education_screen_displayed',
1257
+ 'e_skey_val', 'authkey', 'e_keytype', 'e_regid', 'client_metrics', '_gi',
1258
+ 'e_skey_id', 'feo2_query_status', 'reason', 'fdid', 'recaptcha', '_ga', 'lc'
1259
+ ];
1260
+
1261
+ const REGISTER_PARAM_ORDER = [
1262
+ 'code', 'backup_token', 'sim_mnc', '_gs', 'id', 'device_ram', 'db', '_gg',
1263
+ 'sim_operator_name', 'passkey_login_status', 'lg', 'rc', 'network_radio_type',
1264
+ 'pid', '_ge', 'cellular_strength', 'gpia', 'hasinrc', 'aid',
1265
+ 'network_operator_name', 'mcc', '_gp', 'mnc', 'entered', 'access_session_id',
1266
+ 'sim_mcc', 'roaming_type', 'mistyped', 'airplane_mode_type', 'has_play_store',
1267
+ 'sim_type', 'e_ident', 'e_skey_sig', 'entrypoint', 'in', 'expid', 'simnum',
1268
+ 'cc', 'e_skey_val', 'authkey', 'e_keytype', 'e_regid', '_gi', 'client_metrics',
1269
+ 'e_skey_id', 'fdid', 'recaptcha', '_ga', 'lc'
1270
+ ];
1271
+
1272
+ // Reorder a flat [k, v, k, v, …] list to the native insertion order for the
1273
+ // endpoint. Keys outside the reference order (push_token and any build-specific
1274
+ // extras) keep their relative order and trail the known fields, so nothing is
1275
+ // dropped. Only /v2/code and /v2/register have a reference order; every other
1276
+ // endpoint (client_log, exist, …) and every non-Android device passes through
1277
+ // untouched.
1278
+ function reorderRegistrationPairs(pairs, path, device) {
1279
+ const order = path === '/code' ? CODE_PARAM_ORDER
1280
+ : path === '/register' ? REGISTER_PARAM_ORDER
1281
+ : null;
1282
+ if (!order || !device || device.os !== 'android') return pairs;
1283
+
1284
+ const orderSet = new Set(order);
1285
+ const seen = new Map();
1286
+ const extras = [];
1287
+ for (let i = 0; i < pairs.length; i += 2) {
1288
+ const k = pairs[i];
1289
+ if (seen.has(k)) continue;
1290
+ seen.set(k, pairs[i + 1]);
1291
+ if (!orderSet.has(k)) extras.push(k);
1292
+ }
1293
+
1294
+ const out = [];
1295
+ for (const k of order) if (seen.has(k)) out.push(k, seen.get(k));
1296
+ for (const k of extras) out.push(k, seen.get(k));
1297
+ return out;
1298
+ }
1299
+
1224
1300
  function buildForm(pairs, extraPairs) {
1225
1301
  const parts = [];
1226
1302
  for (let i = 0; i < pairs.length; i += 2) {
@@ -1517,6 +1593,16 @@ function toBase64Url(buf) {
1517
1593
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
1518
1594
  }
1519
1595
 
1596
+ // The native client encodes the Signal-key and expid wire parameters with
1597
+ // Base64.encodeToString(bytes, 11) = URL_SAFE | NO_WRAP | NO_PADDING: authkey
1598
+ // (43 chars), e_ident/e_skey_val (43), e_skey_sig (86), e_regid (6), e_keytype,
1599
+ // e_skey_id (4), expid (22) — never a trailing '='. The padded form still
1600
+ // decodes server-side, but the bare '=' is a wire tell no real device emits.
1601
+ // The ENC envelope and the vname certificate keep their own encoder above.
1602
+ function toBase64UrlNoPad(buf) {
1603
+ return toBase64Url(buf).replace(/=+$/, '');
1604
+ }
1605
+
1520
1606
  // ---------- vname: the Business verified-name certificate ----------
1521
1607
  //
1522
1608
  // A Business registration carries one extra form field, `vname`, and a consumer
@@ -1585,7 +1671,7 @@ function buildVerifiedNameCertificate(store, device) {
1585
1671
  ]));
1586
1672
  }
1587
1673
 
1588
- async function buildPayload(store, waVersion, useToken, extraPairs) {
1674
+ async function buildPayload(store, waVersion, useToken, extraPairs, path) {
1589
1675
  const { cc, national } = parsePhone(store.phoneNumber);
1590
1676
  const meta = getCountryMeta(cc, store);
1591
1677
  const device = store.device || getDeviceConfig();
@@ -1630,29 +1716,34 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
1630
1716
  ga: store._gaToken
1631
1717
  });
1632
1718
 
1633
- return buildForm([
1719
+ const basePairs = [
1634
1720
  'cc', cc,
1635
1721
  'in', national,
1636
1722
  'rc', String(RELEASE_CHANNEL),
1637
1723
  'lg', meta.lg,
1638
1724
  'lc', meta.lc,
1639
- 'authkey', toBase64Url(stripKeyPrefix(store.noiseKeyPair.public)),
1725
+ 'authkey', toBase64UrlNoPad(stripKeyPrefix(store.noiseKeyPair.public)),
1640
1726
  // Business only; buildForm drops a null, so a consumer registration sends
1641
1727
  // the same body it always has.
1642
1728
  'vname', buildVerifiedNameCertificate(store, device),
1643
- 'e_regid', toBase64Url(intToBytes(store.registrationId, 4)),
1644
- 'e_keytype', toBase64Url(Buffer.from([SIGNAL_KEY_TYPE])),
1645
- 'e_ident', toBase64Url(stripKeyPrefix(store.identityKeyPair.public)),
1646
- 'e_skey_id', toBase64Url(intToBytes(store.signedPreKey.id, 3)),
1647
- 'e_skey_val', toBase64Url(stripKeyPrefix(store.signedPreKey.public)),
1648
- 'e_skey_sig', toBase64Url(store.signedPreKey.signature),
1729
+ 'e_regid', toBase64UrlNoPad(intToBytes(store.registrationId, 4)),
1730
+ 'e_keytype', toBase64UrlNoPad(Buffer.from([SIGNAL_KEY_TYPE])),
1731
+ 'e_ident', toBase64UrlNoPad(stripKeyPrefix(store.identityKeyPair.public)),
1732
+ 'e_skey_id', toBase64UrlNoPad(intToBytes(store.signedPreKey.id, 3)),
1733
+ 'e_skey_val', toBase64UrlNoPad(stripKeyPrefix(store.signedPreKey.public)),
1734
+ 'e_skey_sig', toBase64UrlNoPad(store.signedPreKey.signature),
1649
1735
  'fdid', fdid,
1650
- 'expid', toBase64Url(store.deviceId),
1736
+ 'expid', toBase64UrlNoPad(store.deviceId),
1651
1737
  'id', toUrlHex(store.identityId),
1652
1738
  'access_session_id', getAccessSessionId(store),
1653
1739
  'token', token,
1654
1740
  ...attestFields
1655
- ], extraPairs);
1741
+ ];
1742
+ const allPairs = extraPairs ? basePairs.concat(extraPairs) : basePairs;
1743
+ // Emit the fields in the native client's fixed insertion order for /code and
1744
+ // /register. The order is part of the wire fingerprint and, with the query
1745
+ // now mirrored into the URL, is visible to the server in the clear.
1746
+ return buildForm(reorderRegistrationPairs(allPairs, path, device));
1656
1747
  }
1657
1748
 
1658
1749
  // ---------- Encryption (AES-256-GCM, ephemeral X25519) ----------
@@ -1672,9 +1763,9 @@ function encryptPayload(plaintext) {
1672
1763
 
1673
1764
  // ---------- HTTP ----------
1674
1765
 
1675
- function httpPost(path, body, waVersion, authHeader, device) {
1766
+ function httpPost(path, body, waVersion, authHeader, device, query) {
1676
1767
  const proxy = socksProxyUrl();
1677
- if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device);
1768
+ if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device, query);
1678
1769
 
1679
1770
  return new Promise((resolve, reject) => {
1680
1771
  const bodyBuf = Buffer.from(body, 'utf8');
@@ -1685,10 +1776,13 @@ function httpPost(path, body, waVersion, authHeader, device) {
1685
1776
  // Attestation cert-chain / App Attest header (Android cert chain, iOS
1686
1777
  // <attestation>|<keyId>). Only present when body attestation is available.
1687
1778
  if (authHeader) headers['Authorization'] = authHeader;
1779
+ // The native client submits the parameters twice: once in the clear as the
1780
+ // URL query, once encrypted inside ENC. The query is the exact ENC
1781
+ // plaintext, byte for byte.
1688
1782
  const opts = {
1689
1783
  hostname: 'v.whatsapp.net',
1690
1784
  port: 443,
1691
- path: '/v2' + path,
1785
+ path: query ? '/v2' + path + '?' + query : '/v2' + path,
1692
1786
  method: 'POST',
1693
1787
  timeout: 20000,
1694
1788
  headers
@@ -1697,7 +1791,12 @@ function httpPost(path, body, waVersion, authHeader, device) {
1697
1791
  const chunks = [];
1698
1792
  res.on('data', d => chunks.push(d));
1699
1793
  res.on('end', () => {
1700
- const text = Buffer.concat(chunks).toString('utf8');
1794
+ const encoding = String(res.headers['content-encoding'] || '');
1795
+ let buf = Buffer.concat(chunks);
1796
+ if (/gzip/i.test(encoding)) {
1797
+ try { buf = zlib.gunzipSync(buf); } catch (_) { /* keep raw */ }
1798
+ }
1799
+ const text = buf.toString('utf8');
1701
1800
  if (res.statusCode !== 200) {
1702
1801
  return reject(new Error(`HTTP ${res.statusCode} ${path}: ${text}`));
1703
1802
  }
@@ -1713,7 +1812,7 @@ function httpPost(path, body, waVersion, authHeader, device) {
1713
1812
  }
1714
1813
 
1715
1814
  async function sendRequest(path, store, waVersion, useToken, extraPairs) {
1716
- const plaintext = await buildPayload(store, waVersion, useToken, extraPairs);
1815
+ const plaintext = await buildPayload(store, waVersion, useToken, extraPairs, path);
1717
1816
  return sendEncrypted(path, plaintext, store, waVersion);
1718
1817
  }
1719
1818
 
@@ -1741,7 +1840,14 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
1741
1840
 
1742
1841
  _whaDbg('[DBG] REG → ' + path + ' (' + body.length + ' bytes' +
1743
1842
  (bodyAtt.bodyAttestation ? ', attested' : '') + ')');
1744
- const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device);
1843
+ // The ENC plaintext is also the URL query: the native client submits every
1844
+ // parameter twice, in the clear and encrypted, byte for byte identical. This
1845
+ // is documented and verified only for the Android /code and /register
1846
+ // requests, so the mirror is scoped to them; iOS and the funnel endpoints
1847
+ // keep posting to a bare path, exactly as before.
1848
+ const mirrorQuery = device.os === 'android' && (path === '/code' || path === '/register');
1849
+ const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device,
1850
+ mirrorQuery ? plaintext : undefined);
1745
1851
  _whaDbg('[DBG] REG ← ' + path + ' ' +
1746
1852
  (result && result.status ? result.status : JSON.stringify(result).slice(0, 120)) +
1747
1853
  (result && result.reason ? ' reason=' + result.reason : ''));
@@ -1824,7 +1930,7 @@ async function sendFunnelLog(store, waVersion, currentScreen, actionTaken, event
1824
1930
  'in', national,
1825
1931
  'lg', 'en',
1826
1932
  'lc', 'US',
1827
- 'expid', toBase64Url(store.deviceId),
1933
+ 'expid', toBase64UrlNoPad(store.deviceId),
1828
1934
  'fdid', fdid,
1829
1935
  'id', toUrlHex(store.identityId),
1830
1936
  'current_screen', currentScreen,
@@ -1856,7 +1962,7 @@ async function sendPrePnFunnelLog(store, waVersion, actionTaken, eventName) {
1856
1962
  const body = buildForm([
1857
1963
  'lg', 'en',
1858
1964
  'lc', 'US',
1859
- 'expid', toBase64Url(store.deviceId),
1965
+ 'expid', toBase64UrlNoPad(store.deviceId),
1860
1966
  'fdid', fdid,
1861
1967
  'id', toUrlHex(store.identityId),
1862
1968
  'current_screen', 'enter_number',
@@ -2588,7 +2694,7 @@ async function verifyCode(store, code, opts) {
2588
2694
  module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchPlayVersion, fetchAndroidVersionLive, fetchWaVersion, currentVersionFor, refreshSessionVersion, clearVersionCache, tryLoadAndroidMaterial, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
2589
2695
 
2590
2696
  // The HTTP reader, exposed for tests. Not part of the public API.
2591
- module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
2697
+ module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody, decodeBody, reorderRegistrationPairs };
2592
2698
 
2593
2699
  // Token computation, exposed for tests. Not part of the public API.
2594
2700
  module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration, buildVerifiedNameCertificate, buildPayload };
package/lib/Store.js CHANGED
@@ -82,7 +82,11 @@ function createNewStore(phoneNumber, opts) {
82
82
 
83
83
  const registrationId = (crypto.randomBytes(2).readUInt16BE(0) & 0x3fff) + 1;
84
84
  const fdid = uuidv4();
85
- const deviceId = crypto.randomBytes(16);
85
+ // expid goes on the wire as a UUID and WhatsApp reads its version/variant
86
+ // nibbles (the native client builds it with UUID.nameUUIDFromBytes/randomUUID).
87
+ // 16 fully random bytes land on a valid UUID shape only ~1 time in 16, so the
88
+ // server sees a malformed UUID; take the 16 bytes of a real v4 UUID instead.
89
+ const deviceId = Buffer.from(uuidv4().replace(/-/g, ''), 'hex');
86
90
  const identityId = crypto.randomBytes(16);
87
91
  // Device fingerprint fields the native client sends on /code (see
88
92
  // Registration.js): advertising id (UUID) and the 20-byte backup token.
@@ -358,7 +362,8 @@ function fromSixParts(sixParts) {
358
362
  },
359
363
  registrationId: (crypto.randomBytes(2).readUInt16BE(0) & 0x3fff) + 1,
360
364
  fdid: uuidv4(),
361
- deviceId: crypto.randomBytes(16),
365
+ // expid must be a valid UUID on the wire — see createNewStore above.
366
+ deviceId: Buffer.from(uuidv4().replace(/-/g, ''), 'hex'),
362
367
  identityId,
363
368
  advertisingId: uuidv4(),
364
369
  backupToken: crypto.randomBytes(20),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.33.26",
3
+ "version": "5.33.29",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",