whalibmob 5.33.23 → 5.33.28
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/README.md +3 -2
- package/lib/AndroidApk.js +86 -40
- package/lib/Attestation.js +125 -14
- package/lib/Registration.js +232 -42
- package/lib/Store.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ code, and bring the account into being. Both transports, one API.
|
|
|
45
45
|
> wa registration --request-code <number> --method wa_old
|
|
46
46
|
> wa registration --register <number> --code <code>
|
|
47
47
|
> ```
|
|
48
|
-
>
|
|
48
|
+
>
|
|
49
49
|
> **This is optional.** Most numbers register fine with `--method sms` or `--method voice` — try those first. Use the `wa_old` route **only if** you want a guaranteed, 100% success rate on numbers where SMS/voice are blocked.
|
|
50
50
|
|
|
51
51
|
##
|
|
@@ -6171,4 +6171,5 @@ The **Sponsor** button at the top of the repository leads back here.
|
|
|
6171
6171
|
|
|
6172
6172
|
## License
|
|
6173
6173
|
|
|
6174
|
-
MIT
|
|
6174
|
+
MIT
|
|
6175
|
+
.
|
package/lib/AndroidApk.js
CHANGED
|
@@ -215,12 +215,14 @@ function _readStringPool(buf, pos) {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
* { packageName, versionName, versionCode } out of a binary
|
|
219
|
-
* Any field the manifest does not carry comes back null
|
|
218
|
+
* { packageName, versionName, versionCode, permissions } out of a binary
|
|
219
|
+
* AndroidManifest.xml. Any field the manifest does not carry comes back null,
|
|
220
|
+
* and permissions is the list of every <uses-permission> /
|
|
221
|
+
* <uses-permission-sdk-23> name, in manifest order (the _gp hash sorts them).
|
|
220
222
|
*/
|
|
221
223
|
function parseManifest(buf) {
|
|
222
|
-
const
|
|
223
|
-
if (!buf || buf.length < 8) return
|
|
224
|
+
const out = { packageName: null, versionName: null, versionCode: null, permissions: [] };
|
|
225
|
+
if (!buf || buf.length < 8) return out;
|
|
224
226
|
|
|
225
227
|
let strings = null;
|
|
226
228
|
let pos = 8; // past the file's own chunk header
|
|
@@ -238,32 +240,37 @@ function parseManifest(buf) {
|
|
|
238
240
|
const attributeStart = buf.readUInt16LE(ext + 8);
|
|
239
241
|
const attributeSize = buf.readUInt16LE(ext + 10);
|
|
240
242
|
const attributeCount = buf.readUInt16LE(ext + 12);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
243
|
+
const elementName = strings[nameIndex];
|
|
244
|
+
|
|
245
|
+
const attr = (wantName) => {
|
|
246
|
+
for (let i = 0; i < attributeCount; i++) {
|
|
247
|
+
const a = ext + attributeStart + i * attributeSize;
|
|
248
|
+
if (strings[buf.readUInt32LE(a + 4)] !== wantName) continue;
|
|
249
|
+
const rawValue = buf.readUInt32LE(a + 8);
|
|
250
|
+
const dataType = buf[a + 15];
|
|
251
|
+
const data = buf.readUInt32LE(a + 16);
|
|
252
|
+
// A string attribute names its value in the pool twice — the raw
|
|
253
|
+
// source text and the typed value. Either will do; the raw one is
|
|
254
|
+
// absent when the manifest was written without it.
|
|
255
|
+
return (rawValue !== 0xffffffff && strings[rawValue] !== undefined) ? strings[rawValue]
|
|
256
|
+
: (dataType === _TYPE_STRING ? strings[data] : String(data));
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (elementName === 'manifest') {
|
|
262
|
+
out.packageName = attr('package');
|
|
263
|
+
out.versionName = attr('versionName');
|
|
264
|
+
const vc = attr('versionCode');
|
|
265
|
+
out.versionCode = vc == null ? null : (/^\d+$/.test(vc) ? parseInt(vc, 10) : vc);
|
|
266
|
+
} else if (elementName === 'uses-permission' || elementName === 'uses-permission-sdk-23') {
|
|
267
|
+
const name = attr('name');
|
|
268
|
+
if (name) out.permissions.push(name);
|
|
261
269
|
}
|
|
262
|
-
return out;
|
|
263
270
|
}
|
|
264
271
|
pos += chunkSize;
|
|
265
272
|
}
|
|
266
|
-
return
|
|
273
|
+
return out;
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
// A split's own name, as Android files it: "config.xxhdpi" out of
|
|
@@ -339,8 +346,17 @@ function extractMaterial(base, splits) {
|
|
|
339
346
|
|
|
340
347
|
return {
|
|
341
348
|
packageName,
|
|
342
|
-
secretKey:
|
|
343
|
-
classesDexMd5:
|
|
349
|
+
secretKey: deriveSecretKey(packageName, aboutLogo),
|
|
350
|
+
classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
|
|
351
|
+
// What _gi reports about the installed base.apk: the dex, the whole file,
|
|
352
|
+
// its first 10 MiB and its size.
|
|
353
|
+
classesDexSha256: crypto.createHash('sha256').update(classesDex).digest(),
|
|
354
|
+
apkSize: base.length,
|
|
355
|
+
apkSha256Full: crypto.createHash('sha256').update(base).digest(),
|
|
356
|
+
apkSha256First10M: crypto.createHash('sha256').update(base.subarray(0, 0xA00000)).digest(),
|
|
357
|
+
// _gp: the permission-list hash, computed from this APK's own manifest so
|
|
358
|
+
// it tracks the version rather than a pinned constant.
|
|
359
|
+
gpHash: permissionsHash(manifest.permissions),
|
|
344
360
|
certificates,
|
|
345
361
|
// Which drawable the key came from — worth showing, since a bundle carries
|
|
346
362
|
// one per density and only one of them makes the token the server expects.
|
|
@@ -352,6 +368,25 @@ function extractMaterial(base, splits) {
|
|
|
352
368
|
};
|
|
353
369
|
}
|
|
354
370
|
|
|
371
|
+
// The _gp value: base64 SHA-256 of the sorted, separator-less concatenation of
|
|
372
|
+
// the requested permission names, as PackageManager.getRequestedPermissions()
|
|
373
|
+
// hands them to the app. On Android 12+ the platform drops the legacy
|
|
374
|
+
// android.permission.BLUETOOTH when BLUETOOTH_CONNECT is present, so the hash
|
|
375
|
+
// matches the device only once that one name is removed. Returns null when the
|
|
376
|
+
// manifest carried no permissions (nothing to hash).
|
|
377
|
+
const _PERM_BLUETOOTH = 'android.permission.BLUETOOTH';
|
|
378
|
+
const _PERM_BLUETOOTH_CONNECT = 'android.permission.BLUETOOTH_CONNECT';
|
|
379
|
+
|
|
380
|
+
function permissionsHash(permissions) {
|
|
381
|
+
if (!Array.isArray(permissions) || !permissions.length) return null;
|
|
382
|
+
let names = permissions.slice();
|
|
383
|
+
if (names.includes(_PERM_BLUETOOTH_CONNECT)) {
|
|
384
|
+
names = names.filter(n => n !== _PERM_BLUETOOTH);
|
|
385
|
+
}
|
|
386
|
+
names.sort();
|
|
387
|
+
return crypto.createHash('sha256').update(Buffer.from(names.join(''), 'utf8')).digest('base64');
|
|
388
|
+
}
|
|
389
|
+
|
|
355
390
|
// PBKDF2-HMAC-SHA1 over the package name followed by the raw PNG bytes.
|
|
356
391
|
//
|
|
357
392
|
// A JCA factory rejects a binary password, so a hand-rolled loop is the usual
|
|
@@ -417,12 +452,17 @@ function looksLikeWhatsAppCertificate(description) {
|
|
|
417
452
|
|
|
418
453
|
function materialToJson(material) {
|
|
419
454
|
return {
|
|
420
|
-
packageName:
|
|
421
|
-
secretKey:
|
|
422
|
-
classesDexMd5:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
455
|
+
packageName: material.packageName,
|
|
456
|
+
secretKey: material.secretKey.toString('base64'),
|
|
457
|
+
classesDexMd5: material.classesDexMd5.toString('base64'),
|
|
458
|
+
classesDexSha256: material.classesDexSha256 ? material.classesDexSha256.toString('base64') : null,
|
|
459
|
+
apkSize: material.apkSize != null ? material.apkSize : null,
|
|
460
|
+
apkSha256Full: material.apkSha256Full ? material.apkSha256Full.toString('base64') : null,
|
|
461
|
+
apkSha256First10M: material.apkSha256First10M ? material.apkSha256First10M.toString('base64') : null,
|
|
462
|
+
gpHash: material.gpHash || null,
|
|
463
|
+
certificates: material.certificates.map(c => c.toString('base64')),
|
|
464
|
+
apkVersion: material.apkVersion || null,
|
|
465
|
+
apkVersionCode: material.apkVersionCode || null
|
|
426
466
|
};
|
|
427
467
|
}
|
|
428
468
|
|
|
@@ -431,12 +471,17 @@ function materialFromJson(json) {
|
|
|
431
471
|
throw new Error('android APK material file is incomplete');
|
|
432
472
|
}
|
|
433
473
|
return {
|
|
434
|
-
packageName:
|
|
435
|
-
secretKey:
|
|
436
|
-
classesDexMd5:
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
474
|
+
packageName: json.packageName || PERSONAL_PACKAGE,
|
|
475
|
+
secretKey: Buffer.from(json.secretKey, 'base64'),
|
|
476
|
+
classesDexMd5: Buffer.from(json.classesDexMd5, 'base64'),
|
|
477
|
+
classesDexSha256: json.classesDexSha256 ? Buffer.from(json.classesDexSha256, 'base64') : null,
|
|
478
|
+
apkSize: json.apkSize != null ? json.apkSize : null,
|
|
479
|
+
apkSha256Full: json.apkSha256Full ? Buffer.from(json.apkSha256Full, 'base64') : null,
|
|
480
|
+
apkSha256First10M: json.apkSha256First10M ? Buffer.from(json.apkSha256First10M, 'base64') : null,
|
|
481
|
+
gpHash: json.gpHash || null,
|
|
482
|
+
certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
|
|
483
|
+
apkVersion: json.apkVersion || null,
|
|
484
|
+
apkVersionCode: json.apkVersionCode || null
|
|
440
485
|
};
|
|
441
486
|
}
|
|
442
487
|
|
|
@@ -446,6 +491,7 @@ module.exports = {
|
|
|
446
491
|
describeCertificate,
|
|
447
492
|
looksLikeWhatsAppCertificate,
|
|
448
493
|
deriveSecretKey,
|
|
494
|
+
permissionsHash,
|
|
449
495
|
computeToken,
|
|
450
496
|
materialToJson,
|
|
451
497
|
materialFromJson,
|
package/lib/Attestation.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
|
|
2
|
+
//
|
|
3
3
|
// ─── Device attestation client ────────────────────────────────────────────────
|
|
4
4
|
//
|
|
5
5
|
// Talks to the on-device Frida attestation server shipped in ./frida (Android
|
|
@@ -64,15 +64,29 @@ function generateGeToken() {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// ─── _ga: app analytics envelope ─────────────────────────────────────────────
|
|
67
|
-
// bi =
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
// bi AES-256-CBC (key = SHA256(standard base64 of the auth key), random IV
|
|
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
|
|
71
|
+
// ae age of the oldest file in the external files directory, a little
|
|
72
|
+
// younger than ai
|
|
73
|
+
// mp/mu monkey-test flags, false on a device a person is using
|
|
74
|
+
//
|
|
75
|
+
// The ages model a fresh install, drawn per request: fixed numbers would be
|
|
76
|
+
// the same on every registration this library makes, which no phone does.
|
|
77
|
+
function generateGaToken(authKeyRaw) {
|
|
78
|
+
const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
|
|
79
|
+
const plaintext = Buffer.from(crypto.randomBytes(32).toString('base64'), 'utf8');
|
|
80
|
+
const iv = crypto.randomBytes(16);
|
|
81
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
|
|
82
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
83
|
+
const bi = Buffer.concat([iv, ct]).toString('base64').replace(/\//g, '\\/');
|
|
84
|
+
|
|
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
|
|
88
|
+
|
|
89
|
+
const json = '{"bi":"' + bi + '","ap":' + ap + ',"ai":' + ai + ',"mp":false,"ae":' + ae + ',"mu":false}';
|
|
76
90
|
return encodeURIComponent(json);
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -120,6 +134,92 @@ function generateGsToken() {
|
|
|
120
134
|
return encodeURIComponent('{"em":"' + em + '"}');
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
// ─── Integrity envelope shared by _gg and _gi ────────────────────────────────
|
|
138
|
+
// AES-256-CBC, key = SHA256(standard base64 of the 32-byte auth key), a random
|
|
139
|
+
// 16-byte IV prepended to the ciphertext, then standard base64.
|
|
140
|
+
function integrityEnvelope(authKeyRaw, json) {
|
|
141
|
+
const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
|
|
142
|
+
const iv = crypto.randomBytes(16);
|
|
143
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
|
|
144
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(json, 'utf8')), cipher.final()]);
|
|
145
|
+
return encodeURIComponent(Buffer.concat([iv, ct]).toString('base64'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── _gg: Play Integrity status ──────────────────────────────────────────────
|
|
149
|
+
// {"_ic": N, "_it": "<token>"}
|
|
150
|
+
// _ic — 0 on success, 1003 when the provider is not prepared (no GMS)
|
|
151
|
+
// _it — the Play Integrity token, or the literal "undefined" on error
|
|
152
|
+
function generateGgToken(authKeyRaw, icCode, itToken) {
|
|
153
|
+
const json = '{"_ic":' + icCode + ',"_it":"' + String(itToken).replace(/\//g, '\\/') + '"}';
|
|
154
|
+
return integrityEnvelope(authKeyRaw, json);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── _gp: SHA-256 of the manifest permission list ────────────────────────────
|
|
158
|
+
// Base64 SHA-256 of the sorted, separator-less permission names, percent-encoded
|
|
159
|
+
// 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=';
|
|
164
|
+
|
|
165
|
+
function generateGpToken(material) {
|
|
166
|
+
const hash = (material && material.gpHash) || GP_ANDROID12;
|
|
167
|
+
return encodeURIComponent(hash);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── _gi: installation integrity ─────────────────────────────────────────────
|
|
171
|
+
// Ten keys, in this order:
|
|
172
|
+
// _dh SHA256(classes.dex of base.apk)
|
|
173
|
+
// _iln SHA256 of the sorted .so names the app unpacks under its data
|
|
174
|
+
// directory (files/decompressed/libs.spo); fixed per package, below
|
|
175
|
+
// _isb size of base.apk in bytes, as a string
|
|
176
|
+
// _ip package name
|
|
177
|
+
// did Build.ID + "." + Build.VERSION.INCREMENTAL
|
|
178
|
+
// _p ApplicationInfo.sourceDir, the installed base.apk path
|
|
179
|
+
// _ln SHA256 of the names in nativeLibraryDir; that directory is empty on
|
|
180
|
+
// current builds, so the app hashes its fallback string "signal_error"
|
|
181
|
+
// _ist SHA256(first 10 MiB of base.apk)
|
|
182
|
+
// _icr SHA1(DER signing certificate)
|
|
183
|
+
// _is SHA256(base.apk)
|
|
184
|
+
// Values are standard base64 with '/' written as '\/'. Returns null when the
|
|
185
|
+
// material predates these hashes, so the field is omitted instead of faked.
|
|
186
|
+
const GI_ILN = {
|
|
187
|
+
'com.whatsapp': 'i7ls4vAnG2Bvmsp8NDKh7uREGcY4q8Hi4JtdCLix+Cc=',
|
|
188
|
+
'com.whatsapp.w4b': 'Mw3rbAdtB9wUHE6w9vpff27e6I9/ruCa555vkJXh+N4='
|
|
189
|
+
};
|
|
190
|
+
const GI_LN = crypto.createHash('sha256').update('signal_error').digest('base64');
|
|
191
|
+
|
|
192
|
+
// install: { did, sourceDir } — the firmware string and the base.apk path of
|
|
193
|
+
// the install this session claims to be.
|
|
194
|
+
function generateGiToken(authKeyRaw, material, install) {
|
|
195
|
+
if (!material || !material.classesDexSha256 || material.apkSize == null ||
|
|
196
|
+
!material.apkSha256Full || !material.apkSha256First10M ||
|
|
197
|
+
!material.certificates || !material.certificates.length ||
|
|
198
|
+
!install || !install.did || !install.sourceDir) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const pkg = material.packageName || 'com.whatsapp';
|
|
202
|
+
const iln = GI_ILN[pkg];
|
|
203
|
+
if (!iln) return null;
|
|
204
|
+
|
|
205
|
+
const esc = (s) => String(s).replace(/\//g, '\\/');
|
|
206
|
+
const b64 = (buf) => esc(buf.toString('base64'));
|
|
207
|
+
|
|
208
|
+
const json = '{' +
|
|
209
|
+
'"_dh":"' + b64(material.classesDexSha256) + '",' +
|
|
210
|
+
'"_iln":"' + esc(iln) + '",' +
|
|
211
|
+
'"_isb":"' + String(material.apkSize) + '",' +
|
|
212
|
+
'"_ip":"' + esc(pkg) + '",' +
|
|
213
|
+
'"did":"' + esc(install.did) + '",' +
|
|
214
|
+
'"_p":"' + esc(install.sourceDir) + '",' +
|
|
215
|
+
'"_ln":"' + esc(GI_LN) + '",' +
|
|
216
|
+
'"_ist":"' + b64(material.apkSha256First10M) + '",' +
|
|
217
|
+
'"_icr":"' + b64(crypto.createHash('sha1').update(material.certificates[0]).digest()) + '",' +
|
|
218
|
+
'"_is":"' + b64(material.apkSha256Full) + '"' +
|
|
219
|
+
'}';
|
|
220
|
+
return integrityEnvelope(authKeyRaw, json);
|
|
221
|
+
}
|
|
222
|
+
|
|
123
223
|
// ─── Empty (NONE) fallbacks ───────────────────────────────────────────────────
|
|
124
224
|
|
|
125
225
|
const EMPTY_ANDROID = { gpia: '', gg: '', gi: '', gp: '', ge: '', ga: '' };
|
|
@@ -268,13 +368,23 @@ async function attestationFields(device, nonceB64, opts) {
|
|
|
268
368
|
const a = await androidIntegrity(nonceB64 || '', device);
|
|
269
369
|
const gs = generateGsToken();
|
|
270
370
|
const ge = generateGeToken();
|
|
271
|
-
|
|
371
|
+
// The envelope key comes from the Noise static public key; a random one
|
|
372
|
+
// still yields a well-formed field when the caller has no key to give.
|
|
373
|
+
const authKeyRaw = (Buffer.isBuffer(opts.authKeyRaw) && opts.authKeyRaw.length === 32)
|
|
374
|
+
? opts.authKeyRaw
|
|
375
|
+
: crypto.randomBytes(32);
|
|
376
|
+
const ga = opts.ga || generateGaToken(authKeyRaw);
|
|
377
|
+
const gg = a.gpia
|
|
378
|
+
? generateGgToken(authKeyRaw, 0, a.gpia)
|
|
379
|
+
: generateGgToken(authKeyRaw, 1003, 'undefined');
|
|
380
|
+
const gp = generateGpToken(opts.apkMaterial || null);
|
|
381
|
+
const gi = generateGiToken(authKeyRaw, opts.apkMaterial || null, opts.install || null);
|
|
272
382
|
_ok('_gs field generated (WASafe envelope, ' + gs.length + ' encoded chars)');
|
|
273
383
|
return [
|
|
274
384
|
'gpia', orNull(a.gpia),
|
|
275
|
-
'_gg',
|
|
276
|
-
'_gi',
|
|
277
|
-
'_gp',
|
|
385
|
+
'_gg', gg,
|
|
386
|
+
'_gi', gi,
|
|
387
|
+
'_gp', gp,
|
|
278
388
|
'_ge', ge,
|
|
279
389
|
'_ga', ga,
|
|
280
390
|
'_gs', gs,
|
|
@@ -311,6 +421,7 @@ module.exports = {
|
|
|
311
421
|
attestationFields,
|
|
312
422
|
attestBody,
|
|
313
423
|
androidInfo,
|
|
424
|
+
generateGaToken,
|
|
314
425
|
toHex,
|
|
315
426
|
toBase64Url,
|
|
316
427
|
EMPTY_ANDROID,
|
package/lib/Registration.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//registration tool
|
|
2
|
+
//registration tool//
|
|
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':
|
|
45
|
-
'
|
|
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':
|
|
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
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
reqLines.push(
|
|
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) {
|
|
@@ -1339,15 +1415,59 @@ function getAppPid(store) {
|
|
|
1339
1415
|
return store._appPid;
|
|
1340
1416
|
}
|
|
1341
1417
|
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
|
|
1418
|
+
// ---------- the install _gi describes ----------
|
|
1419
|
+
//
|
|
1420
|
+
// sourceDir is where Android put base.apk: two random 16-byte tags, each
|
|
1421
|
+
// URL-safe base64 with padding, as PackageManager names them. did is the
|
|
1422
|
+
// firmware: Build.ID, a dot, Build.VERSION.INCREMENTAL. Samsung's incremental
|
|
1423
|
+
// is the model without "SM-", region XX, update type U, bootloader revision,
|
|
1424
|
+
// major release, year (X = 2024) and month letters and a build digit; other
|
|
1425
|
+
// vendors are given a numeric one. Both belong to one install, so they come
|
|
1426
|
+
// from the session identity like pid and stay put across /code and /register.
|
|
1427
|
+
function getInstallIdentity(store, device, packageName) {
|
|
1428
|
+
const seed = (store.identityId && Buffer.isBuffer(store.identityId))
|
|
1429
|
+
? store.identityId
|
|
1430
|
+
: Buffer.from(String(store.phoneNumber || ''), 'utf8');
|
|
1431
|
+
const h = (label) => crypto.createHash('sha256').update(seed).update(label).digest();
|
|
1432
|
+
const tag = (label) => h(label).subarray(0, 16).toString('base64')
|
|
1433
|
+
.replace(/\+/g, '-').replace(/\//g, '_');
|
|
1434
|
+
|
|
1435
|
+
const sourceDir = '/data/app/~~' + tag('apkdir') + '/' +
|
|
1436
|
+
packageName + '-' + tag('pkgdir') + '/base.apk';
|
|
1437
|
+
|
|
1438
|
+
let incremental = device && device.osBuildIncremental;
|
|
1439
|
+
if (!incremental) {
|
|
1440
|
+
const f = h('firmware');
|
|
1441
|
+
const model = String((device && device.modelId) || '');
|
|
1442
|
+
if (/^SM-/i.test(model)) {
|
|
1443
|
+
incremental = model.replace(/^SM-/i, '').toUpperCase() + 'XXU' +
|
|
1444
|
+
String(1 + f[0] % 3) +
|
|
1445
|
+
'ABC'[f[1] % 3] +
|
|
1446
|
+
'X' +
|
|
1447
|
+
'ABCDEFGHIJKL'[f[2] % 12] +
|
|
1448
|
+
String(1 + f[3] % 9);
|
|
1449
|
+
} else {
|
|
1450
|
+
incremental = String(10000000 + f.readUInt32BE(4) % 90000000);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const buildId = (device && device.osBuildNumber) || 'UP1A.231005.007';
|
|
1454
|
+
|
|
1455
|
+
return { did: buildId + '.' + incremental, sourceDir };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// Whether the request describes a phone with no SIM in it. The operator code
|
|
1459
|
+
// is the tell: a real mcc means a card is present, a missing or all-zero mcc
|
|
1460
|
+
// means none. Every SIM-shaped field (is_sim_absent here, sim_type/simnum and
|
|
1461
|
+
// the operator fields in getRequestVerificationCodeParameters) reads it off
|
|
1462
|
+
// this one test, so a request never says a SIM is both present and absent.
|
|
1463
|
+
function _simAbsent(meta) {
|
|
1348
1464
|
const mcc = meta && meta.mcc != null ? String(meta.mcc).trim() : '';
|
|
1349
|
-
|
|
1350
|
-
|
|
1465
|
+
return !/^\d+$/.test(mcc) || /^0+$/.test(mcc);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function buildClientMetrics(attempt, meta) {
|
|
1469
|
+
const simAbsent = _simAbsent(meta);
|
|
1470
|
+
const json = '{"attempts":' + (attempt == null ? 1 : attempt)
|
|
1351
1471
|
+ ',"app_campaign_download_source":"google-play|unknown"'
|
|
1352
1472
|
+ ',"is_sim_absent":' + (simAbsent ? 'true' : 'false') + '}';
|
|
1353
1473
|
return encodeURIComponent(json);
|
|
@@ -1382,20 +1502,30 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1382
1502
|
// otherwise gets the request answered with an SMS — or with no route at
|
|
1383
1503
|
// all. They stay at their headless-safe values for every other method.
|
|
1384
1504
|
const wantsFlash = method === 'flash';
|
|
1505
|
+
// One SIM story across every field. The operator codes decide it: a real
|
|
1506
|
+
// mcc means a SIM is in the phone, so sim_type and simnum say "present" and
|
|
1507
|
+
// the four operator fields carry the home network. A missing or all-zero
|
|
1508
|
+
// mcc is a phone with no SIM, which reports "000" in all four and both SIM
|
|
1509
|
+
// flags at 0 — the shape a device on Wi-Fi without a card actually sends.
|
|
1510
|
+
// buildClientMetrics reads is_sim_absent off the same test, so the request
|
|
1511
|
+
// never says a SIM is both there and not.
|
|
1512
|
+
const simAbsent = _simAbsent(meta);
|
|
1513
|
+
const simMcc = simAbsent ? '000' : meta.mcc;
|
|
1514
|
+
const simMnc = simAbsent ? '000' : meta.mnc;
|
|
1385
1515
|
return [
|
|
1386
1516
|
'method', method,
|
|
1387
|
-
'sim_mcc',
|
|
1388
|
-
'sim_mnc',
|
|
1517
|
+
'sim_mcc', simMcc,
|
|
1518
|
+
'sim_mnc', simMnc,
|
|
1389
1519
|
'reason', '',
|
|
1390
|
-
'mcc',
|
|
1391
|
-
'mnc',
|
|
1520
|
+
'mcc', simMcc,
|
|
1521
|
+
'mnc', simMnc,
|
|
1392
1522
|
'feo2_query_status', 'success_get_client_capabilities',
|
|
1393
1523
|
'db', '1',
|
|
1394
|
-
'sim_type', '1',
|
|
1524
|
+
'sim_type', simAbsent ? '0' : '1',
|
|
1395
1525
|
'recaptcha', '%7B%22stage%22%3A%22ABPROP_DISABLED%22%7D',
|
|
1396
1526
|
'network_radio_type', '1',
|
|
1397
1527
|
'prefer_sms_over_flash', wantsFlash ? 'true' : 'false',
|
|
1398
|
-
'simnum', '0',
|
|
1528
|
+
'simnum', simAbsent ? '0' : '1',
|
|
1399
1529
|
'airplane_mode_type', '0',
|
|
1400
1530
|
'client_metrics', buildClientMetrics(attempt, meta),
|
|
1401
1531
|
'mistyped', '7',
|
|
@@ -1431,6 +1561,31 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1431
1561
|
];
|
|
1432
1562
|
}
|
|
1433
1563
|
|
|
1564
|
+
// ---------- /register device parameters (Android) ----------
|
|
1565
|
+
//
|
|
1566
|
+
// The Android app sends its device description again on /register, with the
|
|
1567
|
+
// values it sent on /code, so they are taken from the /code list rather than
|
|
1568
|
+
// restated. client_metrics is rebuilt: its attempt counter restarts at 0 on
|
|
1569
|
+
// this screen.
|
|
1570
|
+
const REGISTER_DEVICE_FIELDS = new Set([
|
|
1571
|
+
'backup_token', 'sim_mnc', 'device_ram', 'db', 'network_radio_type', 'pid',
|
|
1572
|
+
'cellular_strength', 'hasinrc', 'aid', 'mcc', 'mnc', 'sim_mcc',
|
|
1573
|
+
'roaming_type', 'mistyped', 'airplane_mode_type', 'sim_type', 'simnum',
|
|
1574
|
+
'recaptcha'
|
|
1575
|
+
]);
|
|
1576
|
+
|
|
1577
|
+
function getRegisterDeviceParameters(store, meta, device) {
|
|
1578
|
+
if (!device || device.os !== 'android') return [];
|
|
1579
|
+
const code = getRequestVerificationCodeParameters(
|
|
1580
|
+
store, store.codeMethod || 'sms', meta, device, 0);
|
|
1581
|
+
const out = [];
|
|
1582
|
+
for (let i = 0; i < code.length; i += 2) {
|
|
1583
|
+
if (REGISTER_DEVICE_FIELDS.has(code[i])) out.push(code[i], code[i + 1]);
|
|
1584
|
+
}
|
|
1585
|
+
out.push('client_metrics', buildClientMetrics(0, meta));
|
|
1586
|
+
return out;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1434
1589
|
// ---------- Payload ----------
|
|
1435
1590
|
|
|
1436
1591
|
// base64url WITH padding — matches Java's Base64.getUrlEncoder().encodeToString()
|
|
@@ -1506,7 +1661,7 @@ function buildVerifiedNameCertificate(store, device) {
|
|
|
1506
1661
|
]));
|
|
1507
1662
|
}
|
|
1508
1663
|
|
|
1509
|
-
async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
1664
|
+
async function buildPayload(store, waVersion, useToken, extraPairs, path) {
|
|
1510
1665
|
const { cc, national } = parsePhone(store.phoneNumber);
|
|
1511
1666
|
const meta = getCountryMeta(cc, store);
|
|
1512
1667
|
const device = store.device || getDeviceConfig();
|
|
@@ -1537,9 +1692,21 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
1537
1692
|
// were wired up.
|
|
1538
1693
|
const pushToken = await pushClientFor(device).getPushToken(store, device);
|
|
1539
1694
|
|
|
1540
|
-
const
|
|
1695
|
+
const authKeyRaw = stripKeyPrefix(store.noiseKeyPair.public);
|
|
1696
|
+
const apkMaterial = device.os === 'android' ? tryLoadAndroidMaterial(device) : null;
|
|
1697
|
+
// One _ga per session: the app sends the same one on /code and /register.
|
|
1698
|
+
if (device.os === 'android' && !store._gaToken) {
|
|
1699
|
+
store._gaToken = attestation.generateGaToken(authKeyRaw);
|
|
1700
|
+
}
|
|
1701
|
+
const attestFields = await attestation.attestationFields(device, nonceB64, {
|
|
1702
|
+
pushToken,
|
|
1703
|
+
authKeyRaw,
|
|
1704
|
+
apkMaterial,
|
|
1705
|
+
install: apkMaterial ? getInstallIdentity(store, device, apkMaterial.packageName) : null,
|
|
1706
|
+
ga: store._gaToken
|
|
1707
|
+
});
|
|
1541
1708
|
|
|
1542
|
-
|
|
1709
|
+
const basePairs = [
|
|
1543
1710
|
'cc', cc,
|
|
1544
1711
|
'in', national,
|
|
1545
1712
|
'rc', String(RELEASE_CHANNEL),
|
|
@@ -1561,7 +1728,12 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
1561
1728
|
'access_session_id', getAccessSessionId(store),
|
|
1562
1729
|
'token', token,
|
|
1563
1730
|
...attestFields
|
|
1564
|
-
]
|
|
1731
|
+
];
|
|
1732
|
+
const allPairs = extraPairs ? basePairs.concat(extraPairs) : basePairs;
|
|
1733
|
+
// Emit the fields in the native client's fixed insertion order for /code and
|
|
1734
|
+
// /register. The order is part of the wire fingerprint and, with the query
|
|
1735
|
+
// now mirrored into the URL, is visible to the server in the clear.
|
|
1736
|
+
return buildForm(reorderRegistrationPairs(allPairs, path, device));
|
|
1565
1737
|
}
|
|
1566
1738
|
|
|
1567
1739
|
// ---------- Encryption (AES-256-GCM, ephemeral X25519) ----------
|
|
@@ -1581,9 +1753,9 @@ function encryptPayload(plaintext) {
|
|
|
1581
1753
|
|
|
1582
1754
|
// ---------- HTTP ----------
|
|
1583
1755
|
|
|
1584
|
-
function httpPost(path, body, waVersion, authHeader, device) {
|
|
1756
|
+
function httpPost(path, body, waVersion, authHeader, device, query) {
|
|
1585
1757
|
const proxy = socksProxyUrl();
|
|
1586
|
-
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device);
|
|
1758
|
+
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device, query);
|
|
1587
1759
|
|
|
1588
1760
|
return new Promise((resolve, reject) => {
|
|
1589
1761
|
const bodyBuf = Buffer.from(body, 'utf8');
|
|
@@ -1594,10 +1766,13 @@ function httpPost(path, body, waVersion, authHeader, device) {
|
|
|
1594
1766
|
// Attestation cert-chain / App Attest header (Android cert chain, iOS
|
|
1595
1767
|
// <attestation>|<keyId>). Only present when body attestation is available.
|
|
1596
1768
|
if (authHeader) headers['Authorization'] = authHeader;
|
|
1769
|
+
// The native client submits the parameters twice: once in the clear as the
|
|
1770
|
+
// URL query, once encrypted inside ENC. The query is the exact ENC
|
|
1771
|
+
// plaintext, byte for byte.
|
|
1597
1772
|
const opts = {
|
|
1598
1773
|
hostname: 'v.whatsapp.net',
|
|
1599
1774
|
port: 443,
|
|
1600
|
-
path: '/v2' + path,
|
|
1775
|
+
path: query ? '/v2' + path + '?' + query : '/v2' + path,
|
|
1601
1776
|
method: 'POST',
|
|
1602
1777
|
timeout: 20000,
|
|
1603
1778
|
headers
|
|
@@ -1606,7 +1781,12 @@ function httpPost(path, body, waVersion, authHeader, device) {
|
|
|
1606
1781
|
const chunks = [];
|
|
1607
1782
|
res.on('data', d => chunks.push(d));
|
|
1608
1783
|
res.on('end', () => {
|
|
1609
|
-
const
|
|
1784
|
+
const encoding = String(res.headers['content-encoding'] || '');
|
|
1785
|
+
let buf = Buffer.concat(chunks);
|
|
1786
|
+
if (/gzip/i.test(encoding)) {
|
|
1787
|
+
try { buf = zlib.gunzipSync(buf); } catch (_) { /* keep raw */ }
|
|
1788
|
+
}
|
|
1789
|
+
const text = buf.toString('utf8');
|
|
1610
1790
|
if (res.statusCode !== 200) {
|
|
1611
1791
|
return reject(new Error(`HTTP ${res.statusCode} ${path}: ${text}`));
|
|
1612
1792
|
}
|
|
@@ -1622,7 +1802,7 @@ function httpPost(path, body, waVersion, authHeader, device) {
|
|
|
1622
1802
|
}
|
|
1623
1803
|
|
|
1624
1804
|
async function sendRequest(path, store, waVersion, useToken, extraPairs) {
|
|
1625
|
-
const plaintext = await buildPayload(store, waVersion, useToken, extraPairs);
|
|
1805
|
+
const plaintext = await buildPayload(store, waVersion, useToken, extraPairs, path);
|
|
1626
1806
|
return sendEncrypted(path, plaintext, store, waVersion);
|
|
1627
1807
|
}
|
|
1628
1808
|
|
|
@@ -1650,7 +1830,14 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
|
|
|
1650
1830
|
|
|
1651
1831
|
_whaDbg('[DBG] REG → ' + path + ' (' + body.length + ' bytes' +
|
|
1652
1832
|
(bodyAtt.bodyAttestation ? ', attested' : '') + ')');
|
|
1653
|
-
|
|
1833
|
+
// The ENC plaintext is also the URL query: the native client submits every
|
|
1834
|
+
// parameter twice, in the clear and encrypted, byte for byte identical. This
|
|
1835
|
+
// is documented and verified only for the Android /code and /register
|
|
1836
|
+
// requests, so the mirror is scoped to them; iOS and the funnel endpoints
|
|
1837
|
+
// keep posting to a bare path, exactly as before.
|
|
1838
|
+
const mirrorQuery = device.os === 'android' && (path === '/code' || path === '/register');
|
|
1839
|
+
const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device,
|
|
1840
|
+
mirrorQuery ? plaintext : undefined);
|
|
1654
1841
|
_whaDbg('[DBG] REG ← ' + path + ' ' +
|
|
1655
1842
|
(result && result.status ? result.status : JSON.stringify(result).slice(0, 120)) +
|
|
1656
1843
|
(result && result.reason ? ' reason=' + result.reason : ''));
|
|
@@ -2403,14 +2590,17 @@ async function verifyCode(store, code, opts) {
|
|
|
2403
2590
|
const screen = currentVerifyScreen(store);
|
|
2404
2591
|
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
2405
2592
|
|
|
2406
|
-
|
|
2593
|
+
// The Android app sends the setup token on /code only; iOS keeps it here.
|
|
2594
|
+
const registerMeta = getCountryMeta(parsePhone(store.phoneNumber).cc, store);
|
|
2595
|
+
const result = await sendRequest('/register', store, waVersion, _device.os !== 'android', [
|
|
2407
2596
|
'code', normalized,
|
|
2408
2597
|
'sim_operator_name', '',
|
|
2409
2598
|
'passkey_login_status', 'not_attempted',
|
|
2410
2599
|
'network_operator_name', '',
|
|
2411
2600
|
'entered', '1',
|
|
2412
2601
|
'has_play_store', 'true',
|
|
2413
|
-
'entrypoint', 'suma'
|
|
2602
|
+
'entrypoint', 'suma',
|
|
2603
|
+
...getRegisterDeviceParameters(store, registerMeta, _device)
|
|
2414
2604
|
]);
|
|
2415
2605
|
|
|
2416
2606
|
const status = result.status;
|
|
@@ -2494,7 +2684,7 @@ async function verifyCode(store, code, opts) {
|
|
|
2494
2684
|
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchPlayVersion, fetchAndroidVersionLive, fetchWaVersion, currentVersionFor, refreshSessionVersion, clearVersionCache, tryLoadAndroidMaterial, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
2495
2685
|
|
|
2496
2686
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
2497
|
-
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
2687
|
+
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody, decodeBody, reorderRegistrationPairs };
|
|
2498
2688
|
|
|
2499
2689
|
// Token computation, exposed for tests. Not part of the public API.
|
|
2500
2690
|
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration, buildVerifiedNameCertificate, buildPayload };
|
|
@@ -2504,7 +2694,7 @@ module.exports._verify = {
|
|
|
2504
2694
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
2505
2695
|
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint,
|
|
2506
2696
|
flashCodeFromCallerId, flashCodeLength, applyFlashFilter, codeForSubmission,
|
|
2507
|
-
getRequestVerificationCodeParameters
|
|
2697
|
+
getRequestVerificationCodeParameters, getRegisterDeviceParameters, getInstallIdentity
|
|
2508
2698
|
};
|
|
2509
2699
|
|
|
2510
2700
|
// Pacing internals, exposed for tests. Not part of the public API.
|
package/lib/Store.js
CHANGED
|
@@ -209,6 +209,11 @@ function storeToJson(store) {
|
|
|
209
209
|
// call it has to know, since that code is read off the caller ID rather
|
|
210
210
|
// than out of a message.
|
|
211
211
|
codeMethod: store.codeMethod || null,
|
|
212
|
+
// /code and /register come from one running app, which sends the same
|
|
213
|
+
// access_session_id and the same _ga in both; the steps are usually
|
|
214
|
+
// separate commands, so both are written down rather than drawn again.
|
|
215
|
+
accessSessionId: store._accessSessionId || null,
|
|
216
|
+
gaToken: store._gaToken || null,
|
|
212
217
|
// Firebase identity behind the push_token. Google issues an android id once
|
|
213
218
|
// and expects it back; discarding it would mint a new phantom device on
|
|
214
219
|
// every registration step, which is worse than carrying one.
|
|
@@ -266,6 +271,8 @@ function storeFromJson(obj) {
|
|
|
266
271
|
simMnc: obj.simMnc || null,
|
|
267
272
|
codePending: obj.codePending || false,
|
|
268
273
|
codeMethod: obj.codeMethod || null,
|
|
274
|
+
_accessSessionId: obj.accessSessionId || undefined,
|
|
275
|
+
_gaToken: obj.gaToken || undefined,
|
|
269
276
|
// Absent on stores written before push tokens existed; null simply means
|
|
270
277
|
// the first registration step will fetch one.
|
|
271
278
|
fcm: obj.fcm || null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.33.
|
|
3
|
+
"version": "5.33.28",
|
|
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",
|