whalibmob 5.33.31 → 5.33.33

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.
package/lib/AndroidApk.js CHANGED
@@ -344,6 +344,26 @@ function extractMaterial(base, splits) {
344
344
  const manifest = parseManifest(readZipEntry(base, entries, 'AndroidManifest.xml'));
345
345
  const packageName = manifest.packageName || PERSONAL_PACKAGE;
346
346
 
347
+ // _lh (login integrity_payload): SHA-256 of libwhatsapp.so as it ships in the
348
+ // APK. On a build with extractNativeLibs=true this is exactly the file
349
+ // nativeLibraryDir holds, so the hash matches; on a superpack build
350
+ // (extractNativeLibs=false) the on-device file can differ, which makes this a
351
+ // best-effort candidate — still better than a pinned constant because it
352
+ // tracks whichever APK the caller supplied. arm64-v8a is preferred, with the
353
+ // 32-bit and x86 ABIs as fallbacks, searched across the base APK and splits.
354
+ let nativeLibSha256 = null;
355
+ for (const abi of ['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']) {
356
+ const p = 'lib/' + abi + '/libwhatsapp.so';
357
+ let so = readZipEntry(base, entries, p);
358
+ if (!so) {
359
+ for (const split of (splits || [])) {
360
+ so = readZipEntry(split.data, readZipDirectory(split.data), p);
361
+ if (so) break;
362
+ }
363
+ }
364
+ if (so) { nativeLibSha256 = crypto.createHash('sha256').update(so).digest(); break; }
365
+ }
366
+
347
367
  return {
348
368
  packageName,
349
369
  secretKey: deriveSecretKey(packageName, aboutLogo),
@@ -357,6 +377,10 @@ function extractMaterial(base, splits) {
357
377
  // _gp: the permission-list hash, computed from this APK's own manifest so
358
378
  // it tracks the version rather than a pinned constant.
359
379
  gpHash: permissionsHash(manifest.permissions),
380
+ // _lh: SHA-256 of libwhatsapp.so from the APK (see above); null when no ABI
381
+ // carries the library, in which case the login integrity_payload falls back
382
+ // to WA_LH_HASH or the documented candidate.
383
+ nativeLibSha256,
360
384
  certificates,
361
385
  // Which drawable the key came from — worth showing, since a bundle carries
362
386
  // one per density and only one of them makes the token the server expects.
@@ -460,6 +484,7 @@ function materialToJson(material) {
460
484
  apkSha256Full: material.apkSha256Full ? material.apkSha256Full.toString('base64') : null,
461
485
  apkSha256First10M: material.apkSha256First10M ? material.apkSha256First10M.toString('base64') : null,
462
486
  gpHash: material.gpHash || null,
487
+ nativeLibSha256: material.nativeLibSha256 ? material.nativeLibSha256.toString('base64') : null,
463
488
  certificates: material.certificates.map(c => c.toString('base64')),
464
489
  apkVersion: material.apkVersion || null,
465
490
  apkVersionCode: material.apkVersionCode || null
@@ -479,6 +504,7 @@ function materialFromJson(json) {
479
504
  apkSha256Full: json.apkSha256Full ? Buffer.from(json.apkSha256Full, 'base64') : null,
480
505
  apkSha256First10M: json.apkSha256First10M ? Buffer.from(json.apkSha256First10M, 'base64') : null,
481
506
  gpHash: json.gpHash || null,
507
+ nativeLibSha256: json.nativeLibSha256 ? Buffer.from(json.nativeLibSha256, 'base64') : null,
482
508
  certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
483
509
  apkVersion: json.apkVersion || null,
484
510
  apkVersionCode: json.apkVersionCode || null
@@ -66,15 +66,23 @@ function generateGeToken() {
66
66
  // ─── _ga: app analytics envelope ─────────────────────────────────────────────
67
67
  // bi AES-256-CBC (key = SHA256(standard base64 of the auth key), random IV
68
68
  // prepended) over the base64 of 32 random bytes: 88 base64 characters
69
- // ap age of base.apk in seconds
70
- // ai age of the oldest file in the data directory
69
+ // ap age of base.apk in seconds — the install is hours to days old and
70
+ // independent of (always older than) ai: a real phone had the app long
71
+ // before it re-registered
72
+ // ai age of the oldest file in the data directory — the session's own setup,
73
+ // minutes to several hours
71
74
  // ae age of the oldest file in the external files directory, a little
72
- // younger than ai
75
+ // younger than ai (tens of seconds to ~30 minutes)
73
76
  // mp/mu monkey-test flags, false on a device a person is using
74
77
  //
75
78
  // The ages model a fresh install, drawn per request: fixed numbers would be
76
79
  // the same on every registration this library makes, which no phone does.
77
- function generateGaToken(authKeyRaw) {
80
+ // The inner JSON object of _ga, without percent-encoding. Shared between the
81
+ // registration _ga parameter (which percent-encodes this) and the login
82
+ // integrity_payload (which embeds the object verbatim). The slashes inside `bi`
83
+ // are escaped here; the integrity_payload builder escapes its other values
84
+ // around this fragment and leaves bi as-is, matching the live blob.
85
+ function buildGaInner(authKeyRaw) {
78
86
  const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
79
87
  const plaintext = Buffer.from(crypto.randomBytes(32).toString('base64'), 'utf8');
80
88
  const iv = crypto.randomBytes(16);
@@ -82,12 +90,18 @@ function generateGaToken(authKeyRaw) {
82
90
  const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
83
91
  const bi = Buffer.concat([iv, ct]).toString('base64').replace(/\//g, '\\/');
84
92
 
85
- const ai = 120 + crypto.randomInt(7081); // 2 min .. 2 h
86
- const ap = Math.max(1, ai + crypto.randomInt(601) - 300); // ai ± 5 min
87
- const ae = ai - (30 + crypto.randomInt(91)); // ai − 30..120 s
93
+ // A live client was observed sending ap≈4 days ≫ ai≈5.5 h > ae (ai − ~26 min).
94
+ // The earlier model made ap≈ai, which no real install shows: base.apk predates
95
+ // the data directory by far more than a few minutes.
96
+ const ai = 600 + crypto.randomInt(28800); // 10 min .. 8 h
97
+ const ap = ai + 3600 + crypto.randomInt(864000); // ai + 1 h .. +10 d
98
+ const ae = Math.max(1, ai - (30 + crypto.randomInt(1771))); // ai − 30 s .. 30 min
88
99
 
89
- const json = '{"bi":"' + bi + '","ap":' + ap + ',"ai":' + ai + ',"mp":false,"ae":' + ae + ',"mu":false}';
90
- return encodeURIComponent(json);
100
+ return '{"bi":"' + bi + '","ap":' + ap + ',"ai":' + ai + ',"mp":false,"ae":' + ae + ',"mu":false}';
101
+ }
102
+
103
+ function generateGaToken(authKeyRaw) {
104
+ return encodeURIComponent(buildGaInner(authKeyRaw));
91
105
  }
92
106
 
93
107
  function generateGsToken() {
@@ -157,14 +171,41 @@ function generateGgToken(authKeyRaw, icCode, itToken) {
157
171
  // ─── _gp: SHA-256 of the manifest permission list ────────────────────────────
158
172
  // Base64 SHA-256 of the sorted, separator-less permission names, percent-encoded
159
173
  // on the wire. Computed from the installed APK's own manifest (material.gpHash)
160
- // so it tracks the version. The constant is the known value for 2.26.35.75+ on
161
- // Android 12+ (85 permissions) and is used when no material hash is available —
162
- // an older material file, or a build read without the manifest permissions.
163
- const GP_ANDROID12 = 'XpccAx8EKQswlTL2MtpCnGIMXTCaUbAf95ElLECc6jk=';
174
+ // so it tracks the version — that is always preferred when a real APK is loaded.
175
+ // When no material hash is available (a headless run, or a build read without
176
+ // the manifest permissions), WA_GP_HASH overrides the fallback for a specific
177
+ // version, and otherwise GP_DEFAULT is sent: the value a live current client was
178
+ // observed sending. The previous known value for 2.26.35.75+ on Android 12
179
+ // (85 permissions) was 'XpccAx8EKQswlTL2MtpCnGIMXTCaUbAf95ElLECc6jk='.
180
+ const GP_DEFAULT = 'q19gmaQaITVtyoBCdLsD7CiZKryGA9zltE8yTd30NdI=';
181
+
182
+ function gpHashValue(material) {
183
+ return (material && material.gpHash) ||
184
+ (process.env.WA_GP_HASH && process.env.WA_GP_HASH.trim()) ||
185
+ GP_DEFAULT;
186
+ }
164
187
 
165
188
  function generateGpToken(material) {
166
- const hash = (material && material.gpHash) || GP_ANDROID12;
167
- return encodeURIComponent(hash);
189
+ return encodeURIComponent(gpHashValue(material));
190
+ }
191
+
192
+ // ─── _lh: hash of the native library from nativeLibraryDir ────────────────────
193
+ // _lh = base64(SHA-256(<nativeLibraryDir>/libwhatsapp.so)). It appears only
194
+ // inside the login integrity_payload (never in the registration _gi), new in
195
+ // 2.26.37.73. A headless client has no nativeLibraryDir to read — modern builds
196
+ // ship the libraries in the superpack and leave that directory empty — so the
197
+ // exact on-device file cannot be observed here. The value is taken, in order,
198
+ // from the extracted APK material (AndroidApk hashes libwhatsapp.so out of the
199
+ // APK, which matches the device on extractNativeLibs=true builds and is the best
200
+ // candidate otherwise), the WA_LH_HASH override, or the documented per-version
201
+ // candidate for libwhatsapp.so (2.26.35.75). This mirrors how _gp falls back
202
+ // when no manifest hash is available.
203
+ const LH_DEFAULT = 'euEs+17faTS6Mt7WHjzn7spB95xywNpuimy0sWbyuME=';
204
+
205
+ function lhHashValue(material) {
206
+ const m = material && material.nativeLibSha256;
207
+ if (m) return Buffer.isBuffer(m) ? m.toString('base64') : m;
208
+ return (process.env.WA_LH_HASH && process.env.WA_LH_HASH.trim()) || LH_DEFAULT;
168
209
  }
169
210
 
170
211
  // ─── _gi: installation integrity ─────────────────────────────────────────────
@@ -220,6 +261,62 @@ function generateGiToken(authKeyRaw, material, install) {
220
261
  return integrityEnvelope(authKeyRaw, json);
221
262
  }
222
263
 
264
+ // ─── Post-login integrity_payload ─────────────────────────────────────────────
265
+ // The blob the client hands over in <ib><integrity_payload> after the first
266
+ // login, in response to the server's <ib><safetynet><integrity nonce='…'>. It is
267
+ // the same AES-256-CBC envelope as the registration _gi (key
268
+ // SHA256(std-base64(authkey)), IV‖ciphertext, PKCS7), but the plaintext JSON has
269
+ // a fixed, wider key order and nests _ge and _ga as objects rather than as the
270
+ // separate percent-encoded parameters they are at registration:
271
+ //
272
+ // aid, _dh, _lh, _iln, _ln, _ge, _gp, _ga, _ip, _isb, _n, _icr, _is
273
+ //
274
+ // _n echoes the server's safetynet nonce in plaintext inside the envelope, which
275
+ // binds the blob to the request. The result is standard base64 (not URL-safe,
276
+ // not percent-encoded): it is the text content of the <integrity_payload> node.
277
+ //
278
+ // Returns null when the material/identity needed to build a faithful blob is
279
+ // missing, so the caller sends nothing rather than a half-formed envelope.
280
+ function generateIntegrityPayload(opts) {
281
+ opts = opts || {};
282
+ const { material, aid, serverNonce } = opts;
283
+ const authKeyRaw = (Buffer.isBuffer(opts.authKeyRaw) && opts.authKeyRaw.length === 32)
284
+ ? opts.authKeyRaw : null;
285
+ if (!authKeyRaw || !aid || !serverNonce) return null;
286
+ if (!material || !material.classesDexSha256 || material.apkSize == null ||
287
+ !material.apkSha256Full || !material.certificates || !material.certificates.length) {
288
+ return null;
289
+ }
290
+ const pkg = material.packageName || 'com.whatsapp';
291
+ const iln = GI_ILN[pkg];
292
+ if (!iln) return null;
293
+
294
+ const esc = (s) => String(s).replace(/\//g, '\\/');
295
+ const b64 = (buf) => esc(buf.toString('base64'));
296
+
297
+ const json = '{' +
298
+ '"aid":"' + esc(aid) + '",' +
299
+ '"_dh":"' + b64(material.classesDexSha256) + '",' +
300
+ '"_lh":"' + esc(lhHashValue(material)) + '",' +
301
+ '"_iln":"' + esc(iln) + '",' +
302
+ '"_ln":"' + esc(GI_LN) + '",' +
303
+ '"_ge":' + '{"sb":false,"sv":false}' + ',' +
304
+ '"_gp":"' + esc(gpHashValue(material)) + '",' +
305
+ '"_ga":' + buildGaInner(authKeyRaw) + ',' +
306
+ '"_ip":"' + esc(pkg) + '",' +
307
+ '"_isb":"' + String(material.apkSize) + '",' +
308
+ '"_n":"' + esc(serverNonce) + '",' +
309
+ '"_icr":"' + b64(crypto.createHash('sha1').update(material.certificates[0]).digest()) + '",' +
310
+ '"_is":"' + b64(material.apkSha256Full) + '"' +
311
+ '}';
312
+
313
+ const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
314
+ const iv = crypto.randomBytes(16);
315
+ const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
316
+ const ct = Buffer.concat([cipher.update(Buffer.from(json, 'utf8')), cipher.final()]);
317
+ return Buffer.concat([iv, ct]).toString('base64');
318
+ }
319
+
223
320
  // ─── Empty (NONE) fallbacks ───────────────────────────────────────────────────
224
321
 
225
322
  const EMPTY_ANDROID = { gpia: '', gg: '', gi: '', gp: '', ge: '', ga: '' };
@@ -422,6 +519,7 @@ module.exports = {
422
519
  attestBody,
423
520
  androidInfo,
424
521
  generateGaToken,
522
+ generateIntegrityPayload,
425
523
  toHex,
426
524
  toBase64Url,
427
525
  EMPTY_ANDROID,
package/lib/Client.js CHANGED
@@ -1702,6 +1702,15 @@ class WhalibmobClient extends EventEmitter {
1702
1702
 
1703
1703
  this._parseSuccessNode(successNode);
1704
1704
 
1705
+ // The successful-login counter (ClientPayload field 24, lc) advances: the
1706
+ // value just announced in the handshake was the pre-login count, and this
1707
+ // login is now accepted. Wraps to 0 past int32 max, as the reference does.
1708
+ {
1709
+ const next = (this._store.connectionLc || 0) + 1;
1710
+ this._store.connectionLc = next > 0x7fffffff ? 0 : next;
1711
+ this._persistStore();
1712
+ }
1713
+
1705
1714
  // ── Feature 3 (part A): Send <active> IQ ─────────────────────────────────
1706
1715
  // WhatsApp opens sessions as "passive" — the client must explicitly
1707
1716
  // activate the connection before the server will deliver messages.
@@ -1741,6 +1750,14 @@ class WhalibmobClient extends EventEmitter {
1741
1750
  _whaDbg('[DBG] AB_PROPS_FETCH_FAILED ' + (err && err.message)));
1742
1751
  }
1743
1752
 
1753
+ // First login only: check the terms-of-service notice the app checks right
1754
+ // after its first <success>. A fresh account is already accepted
1755
+ // server-side, so this is a quiet confirmation, not a prompt — but a client
1756
+ // that never asks is one more way the session reads as not the app.
1757
+ if (!this._hasConnectedOnce) {
1758
+ this._acceptLoginTos();
1759
+ }
1760
+
1744
1761
  // ── Reconnect: flush stale device cache + background re-usync ─────────────
1745
1762
  // On first connect _hasConnectedOnce is false — nothing extra to do.
1746
1763
  // On every reconnect (network drop / NAT reset / server restart) we flush the
@@ -1916,9 +1933,57 @@ class WhalibmobClient extends EventEmitter {
1916
1933
  this._sendAppStateSyncForTypes(dirtyTypes);
1917
1934
  }
1918
1935
 
1936
+ // After the first login the server asks for a SafetyNet attestation with
1937
+ // <ib><safetynet><integrity nonce='…'/></safetynet></ib>. The client answers
1938
+ // in the same window with <ib><integrity_payload> (and a keystore chain that
1939
+ // needs a real TEE, which a headless client cannot produce). We send the
1940
+ // integrity_payload leg, which is the same AES envelope as the registration
1941
+ // _gi and so is reproducible here.
1942
+ for (const child of children) {
1943
+ if (!child || child.description !== 'safetynet') continue;
1944
+ const integ = Array.isArray(child.content)
1945
+ ? child.content.find(c => c && c.description === 'integrity')
1946
+ : null;
1947
+ const nonce = integ && integ.attrs && integ.attrs.nonce;
1948
+ if (nonce) this._sendIntegrityPayload(String(nonce));
1949
+ }
1950
+
1919
1951
  this.emit('ib', { node });
1920
1952
  }
1921
1953
 
1954
+ // Build and send <ib><integrity_payload> in response to the server's SafetyNet
1955
+ // nonce. Opt-in through WA_INTEGRITY_PAYLOAD: the envelope is faithful, but the
1956
+ // native-library hash (_lh) cannot be read on a headless client, so the blob
1957
+ // carries a best-known value and is sent only when asked for. Android only,
1958
+ // and only when the APK material needed to fill the blob is present.
1959
+ _sendIntegrityPayload(nonce) {
1960
+ try {
1961
+ if (!process.env.WA_INTEGRITY_PAYLOAD || !this._socket) return;
1962
+ const device = this._store.device;
1963
+ if (!device || device.os !== 'android') return;
1964
+
1965
+ const Registration = require('./Registration');
1966
+ const attestation = require('./Attestation');
1967
+ const material = Registration.tryLoadAndroidMaterial(device);
1968
+ if (!material) return;
1969
+
1970
+ const blob = attestation.generateIntegrityPayload({
1971
+ authKeyRaw: stripKeyPrefix(this._store.noiseKeyPair.public),
1972
+ material,
1973
+ aid: Registration._verify.deviceAid(this._store),
1974
+ serverNonce: nonce
1975
+ });
1976
+ if (!blob) return;
1977
+
1978
+ this._socket.sendNode(new BinaryNode('ib', {}, [
1979
+ new BinaryNode('integrity_payload', {}, blob)
1980
+ ]));
1981
+ _whaDbg('[DBG] INTEGRITY_PAYLOAD sent (' + blob.length + ' b64 chars)');
1982
+ } catch (err) {
1983
+ _whaDbg('[DBG] INTEGRITY_PAYLOAD failed: ' + (err && err.message));
1984
+ }
1985
+ }
1986
+
1922
1987
  // ── Feature 3 (part C): Send an app-state-sync IQ ──────────────────────────
1923
1988
  //
1924
1989
  // Maps WhatsApp dirty-type names → the actual collection names used in the
@@ -7252,6 +7317,26 @@ class WhalibmobClient extends EventEmitter {
7252
7317
  * `refresh` is how many seconds the server suggests waiting before
7253
7318
  * asking again.
7254
7319
  */
7320
+ // The notice the native client checks on its first login — the general WhatsApp
7321
+ // terms. Captured post-<success> as <iq xmlns='tos' type='get'><request>
7322
+ // <notice id='20210210'/></request>.
7323
+ static get FIRST_LOGIN_TOS_NOTICE() { return '20210210'; }
7324
+
7325
+ // Fire-and-forget first-login terms check. Queries the standard notice and, if
7326
+ // the server reports it outstanding, accepts it. Every failure is swallowed:
7327
+ // the account works whether or not this round-trip lands, exactly as the other
7328
+ // on-connect syncs do.
7329
+ _acceptLoginTos() {
7330
+ const notice = WhalibmobClient.FIRST_LOGIN_TOS_NOTICE;
7331
+ this.queryTosNotices([notice])
7332
+ .then((parsed) => {
7333
+ const outstanding = parsed.notices.filter(n => !n.accepted).map(n => n.id);
7334
+ if (outstanding.length) return this.acceptTosNotices(outstanding);
7335
+ })
7336
+ .then(() => _whaDbg('[DBG] TOS first-login check done'))
7337
+ .catch(err => _whaDbg('[DBG] TOS first-login check failed: ' + (err && err.message)));
7338
+ }
7339
+
7255
7340
  async queryTosNotices(noticeIds) {
7256
7341
  if (!this._connected) throw new Error('Not connected');
7257
7342
  if (!Array.isArray(noticeIds)) throw new Error('queryTosNotices: noticeIds must be an array');
@@ -73,6 +73,10 @@ function getDeviceConfig() {
73
73
  osVersion: process.env.WA_DEVICE_OS_VERSION || '14',
74
74
  osBuildNumber: process.env.WA_DEVICE_BUILD || 'UP1A.231005.007',
75
75
  modelId: process.env.WA_DEVICE_MODEL_ID || 'SM-S928B',
76
+ // Build.BOARD, announced as userAgent field 13 when set. Left unset by
77
+ // default: the native client omits the field when the board is empty, so a
78
+ // guessed value would be worse than none.
79
+ board: process.env.WA_DEVICE_BOARD || undefined,
76
80
  // What the handset reports as usable RAM, in GiB. Registration sends it,
77
81
  // and it has to belong to the handset beside it — see deviceRam().
78
82
  ram: process.env.WA_DEVICE_RAM || '11.55',
@@ -1244,7 +1244,7 @@ function toUrlHex(buf) {
1244
1244
 
1245
1245
  // The native client inserts registration fields into a LinkedHashMap in a
1246
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
1247
+ // the live orders captured from WhatsApp Android reverse engerring: 50 fields
1248
1248
  // for /v2/code, 49 for /v2/register. gpia/token/vname that this build omits
1249
1249
  // simply drop out — their slots collapse.
1250
1250
  const CODE_PARAM_ORDER = [
@@ -2704,7 +2704,7 @@ module.exports._verify = {
2704
2704
  hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
2705
2705
  normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint,
2706
2706
  flashCodeFromCallerId, flashCodeLength, applyFlashFilter, codeForSubmission,
2707
- getRequestVerificationCodeParameters, getRegisterDeviceParameters, getInstallIdentity
2707
+ getRequestVerificationCodeParameters, getRegisterDeviceParameters, getInstallIdentity, deviceAid
2708
2708
  };
2709
2709
 
2710
2710
  // Pacing internals, exposed for tests. Not part of the public API.
package/lib/Store.js CHANGED
@@ -116,6 +116,7 @@ function createNewStore(phoneNumber, opts) {
116
116
  backupToken,
117
117
  registered: false,
118
118
  codePending: false, // true after /code request, cleared on successful /register
119
+ connectionLc: 0, // successful-login counter (ClientPayload field 24)
119
120
  name: normalizePushName(opts.name) || 'User',
120
121
  // Which SIM is actually in the phone. The country table can only guess one
121
122
  // operator per calling code — number portability broke the prefix→operator
@@ -218,6 +219,10 @@ function storeToJson(store) {
218
219
  // separate commands, so both are written down rather than drawn again.
219
220
  accessSessionId: store._accessSessionId || null,
220
221
  gaToken: store._gaToken || null,
222
+ // Successful-login counter (ClientPayload field 24, lc). Starts at 0 on
223
+ // registration and increments after each accepted <success>, so the number
224
+ // the account logs in with grows the way a real install's does.
225
+ connectionLc: store.connectionLc || 0,
221
226
  // Firebase identity behind the push_token. Google issues an android id once
222
227
  // and expects it back; discarding it would mint a new phantom device on
223
228
  // every registration step, which is worse than carrying one.
@@ -277,6 +282,9 @@ function storeFromJson(obj) {
277
282
  codeMethod: obj.codeMethod || null,
278
283
  _accessSessionId: obj.accessSessionId || undefined,
279
284
  _gaToken: obj.gaToken || undefined,
285
+ // Absent on stores written before the counter existed; 0 is the same value
286
+ // a fresh registration starts from.
287
+ connectionLc: obj.connectionLc || 0,
280
288
  // Absent on stores written before push tokens existed; null simply means
281
289
  // the first registration step will fetch one.
282
290
  fcm: obj.fcm || null,
package/lib/noise.js CHANGED
@@ -182,6 +182,37 @@ const CONNECT_TYPE_WIFI = 1; // WIFI_UNKNOWN
182
182
  const CONNECT_REASON_USER_ACTIVATED = 1;
183
183
  const DEVICE_TYPE_PHONE = 0;
184
184
 
185
+ // ClientPayload yearClass (field 36) and memClass (field 37): stationary device
186
+ // performance classes the native client derives from RAM/CPU and caches once per
187
+ // install. The server expects them on every login and watches for jumps as a
188
+ // device-change signal, so they are derived from the declared RAM (reported
189
+ // GiB, below nominal) to stay stable for a given profile. The native yearClass
190
+ // also folds in CPU frequency and core count; with only RAM at hand the RAM rows
191
+ // of the 2016 scale are used, which decide the class for all but the oldest
192
+ // hardware. memClass mirrors ActivityManager.getMemoryClass() — the per-app heap
193
+ // in MiB the platform assigns by RAM tier.
194
+ function yearClassFromRam(ramGiB) {
195
+ const bytes = Number(ramGiB) * 1073741824;
196
+ if (!Number.isFinite(bytes) || bytes <= 0) return 2015;
197
+ if (bytes <= 805306368) return 2010; // ≤ 768 MiB
198
+ if (bytes <= 1073741824) return 2012; // ≤ 1 GiB
199
+ if (bytes <= 1610612736) return 2013; // ≤ 1.5 GiB
200
+ if (bytes <= 2147483648) return 2013; // ≤ 2 GiB
201
+ if (bytes <= 3221225472) return 2014; // ≤ 3 GiB
202
+ if (bytes <= 5368709120) return 2015; // ≤ 5 GiB
203
+ return 2016; // > 5 GiB
204
+ }
205
+
206
+ function memClassFromRam(ramGiB) {
207
+ const g = Number(ramGiB);
208
+ if (!Number.isFinite(g) || g <= 0) return 256;
209
+ if (g < 1) return 48;
210
+ if (g < 2) return 96;
211
+ if (g < 3) return 128;
212
+ if (g < 4) return 192;
213
+ return 256;
214
+ }
215
+
185
216
  // ─── Curve25519 DH helpers ───────────────────────────────────────────────────
186
217
 
187
218
  function stripKeyPrefix(pub) {
@@ -596,6 +627,23 @@ class NoiseSocket extends EventEmitter {
596
627
  connectAttemptCount: (this.store.connectAttemptCount || 0),
597
628
  device: 0,
598
629
  oc: false,
630
+ // A real client always sends these. sessionId is a per-login-attempt
631
+ // int; lc is the successful-login counter persisted on the store (0 on
632
+ // the first login, incremented after each <success>); yearClass/memClass
633
+ // are the device performance classes, stable for this profile.
634
+ sessionId: crypto.randomBytes(4).readUInt32BE(0) & 0x7fffffff,
635
+ lc: (this.store.connectionLc || 0),
636
+ yearClass: yearClassFromRam(_device.ram),
637
+ memClass: memClassFromRam(_device.ram),
638
+ // The remaining fields a real client always writes. dnsSource reports
639
+ // how the chat host was resolved (SYSTEM by default, or SOCKS_PROXY when
640
+ // a proxy transport is in use); trafficAnonymization is OFF unless the
641
+ // account enabled it; connectionSequenceInfo packs the port and host
642
+ // category — port 443 (1) and the primary host (1<<2) give 5 on a direct
643
+ // first connect.
644
+ dnsSource: { dnsMethod: require('./socks').socksProxyUrl() ? 7 : 0, appCached: false },
645
+ trafficAnonymization: 0,
646
+ connectionSequenceInfo: 5,
599
647
  userAgent: {
600
648
  // Which client this says it is. The server checks the announced
601
649
  // version against *this* platform, so a wrong number here comes back
@@ -635,6 +683,14 @@ class NoiseSocket extends EventEmitter {
635
683
  releaseChannel: 0,
636
684
  localeLanguage: _phoneMeta.lg,
637
685
  localeCountry: _phoneMeta.lc,
686
+ // Build.BOARD (14→13): sent only when the profile carries one, as the
687
+ // native client does. deviceExpId (14) is the installation expid
688
+ // lowercased — the same value registration sends as `expid` — which
689
+ // the native client always writes.
690
+ deviceBoard: _device.board || undefined,
691
+ deviceExpId: this.store.deviceId
692
+ ? this.store.deviceId.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '').toLowerCase()
693
+ : undefined,
638
694
  // DeviceType enum (field 15): PHONE. Every profile shipped here is a
639
695
  // phone — the numeric `deviceModelType` on the profiles is unrelated
640
696
  // to this and is read by nothing.
package/lib/proto.js CHANGED
@@ -33,7 +33,7 @@ function encodeVersion(major, minor, patch, q) {
33
33
  return Buffer.concat(parts);
34
34
  }
35
35
 
36
- function encodeUserAgent({ platform, version, mcc, mnc, osVersion, manufacturer, device, osBuildNumber, phoneId, releaseChannel, localeLanguage, localeCountry, deviceType, deviceModelType }) {
36
+ function encodeUserAgent({ platform, version, mcc, mnc, osVersion, manufacturer, device, osBuildNumber, phoneId, releaseChannel, localeLanguage, localeCountry, deviceBoard, deviceExpId, deviceType, deviceModelType }) {
37
37
  const versionBuf = encodeVersion(...version.split('.').map(Number));
38
38
  const parts = [
39
39
  field(1, VARINT, varint(platform)),
@@ -49,12 +49,27 @@ function encodeUserAgent({ platform, version, mcc, mnc, osVersion, manufacturer,
49
49
  parts.push(field(10, VARINT, varint(releaseChannel || 0)));
50
50
  parts.push(field(11, LEN, string(localeLanguage || 'en')));
51
51
  parts.push(field(12, LEN, string(localeCountry || 'US')));
52
+ // Build.BOARD (13) — only when the profile carries one, matching the native
53
+ // client, which sets it only if the string is non-empty.
54
+ if (deviceBoard) parts.push(field(13, LEN, string(deviceBoard)));
55
+ // deviceExpId (14) — the installation expid, lowercased. The native client
56
+ // always writes it (the value may be empty), so it is sent unconditionally.
57
+ if (deviceExpId !== undefined) parts.push(field(14, LEN, string(deviceExpId)));
52
58
  if (deviceType !== undefined) parts.push(field(15, VARINT, varint(deviceType)));
53
59
  if (deviceModelType) parts.push(field(16, LEN, string(deviceModelType)));
54
60
  return Buffer.concat(parts);
55
61
  }
56
62
 
57
- function encodeClientPayload({ username, passive, userAgent, pushName, shortConnect, connectType, connectReason, connectAttemptCount, device, oc }) {
63
+ // dnsSource (ClientPayload field 15, message C1J4): field 15 dnsMethod (enum),
64
+ // field 16 appCached (bool). The inner message reuses the numbers 15/16 — that
65
+ // is how the native schema is laid out.
66
+ function encodeDnsSource({ dnsMethod, appCached }) {
67
+ const parts = [field(15, VARINT, varint(dnsMethod || 0))];
68
+ if (appCached !== undefined) parts.push(field(16, VARINT, bool(appCached)));
69
+ return Buffer.concat(parts);
70
+ }
71
+
72
+ function encodeClientPayload({ username, passive, userAgent, pushName, sessionId, shortConnect, connectType, connectReason, dnsSource, connectAttemptCount, device, oc, lc, yearClass, memClass, trafficAnonymization, connectionSequenceInfo }) {
58
73
  const uaBuf = encodeUserAgent(userAgent);
59
74
  const parts = [
60
75
  field(1, VARINT, varint(username)),
@@ -62,12 +77,19 @@ function encodeClientPayload({ username, passive, userAgent, pushName, shortConn
62
77
  field(5, LEN, bytes(uaBuf))
63
78
  ];
64
79
  if (pushName) parts.push(field(7, LEN, string(pushName)));
80
+ if (sessionId !== undefined) parts.push(field(9, VARINT, varint(sessionId)));
65
81
  if (shortConnect !== undefined) parts.push(field(10, VARINT, bool(shortConnect)));
66
82
  if (connectType !== undefined) parts.push(field(12, VARINT, varint(connectType)));
67
83
  if (connectReason !== undefined) parts.push(field(13, VARINT, varint(connectReason)));
84
+ if (dnsSource !== undefined) parts.push(field(15, LEN, bytes(encodeDnsSource(dnsSource))));
68
85
  if (connectAttemptCount !== undefined) parts.push(field(16, VARINT, varint(connectAttemptCount)));
69
86
  if (device !== undefined) parts.push(field(18, VARINT, varint(device)));
70
87
  if (oc !== undefined) parts.push(field(23, VARINT, bool(oc)));
88
+ if (lc !== undefined) parts.push(field(24, VARINT, varint(lc)));
89
+ if (yearClass !== undefined) parts.push(field(36, VARINT, varint(yearClass)));
90
+ if (memClass !== undefined) parts.push(field(37, VARINT, varint(memClass)));
91
+ if (trafficAnonymization !== undefined) parts.push(field(40, VARINT, varint(trafficAnonymization)));
92
+ if (connectionSequenceInfo !== undefined) parts.push(field(43, VARINT, varint(connectionSequenceInfo)));
71
93
  return Buffer.concat(parts);
72
94
  }
73
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.33.31",
3
+ "version": "5.33.33",
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",