whalibmob 5.33.30 → 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 +26 -0
- package/lib/Attestation.js +113 -15
- package/lib/Client.js +85 -0
- package/lib/DeviceConfig.js +4 -0
- package/lib/Registration.js +2 -2
- package/lib/Store.js +8 -0
- package/lib/noise.js +56 -0
- package/lib/proto.js +24 -2
- package/package.json +1 -1
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
|
package/lib/Attestation.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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');
|
package/lib/DeviceConfig.js
CHANGED
|
@@ -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',
|
package/lib/Registration.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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",
|