whalibmob 5.33.22 → 5.33.26
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 +113 -19
- 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,5 +1,5 @@
|
|
|
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');
|
|
@@ -1339,15 +1339,59 @@ function getAppPid(store) {
|
|
|
1339
1339
|
return store._appPid;
|
|
1340
1340
|
}
|
|
1341
1341
|
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
|
|
1342
|
+
// ---------- the install _gi describes ----------
|
|
1343
|
+
//
|
|
1344
|
+
// sourceDir is where Android put base.apk: two random 16-byte tags, each
|
|
1345
|
+
// URL-safe base64 with padding, as PackageManager names them. did is the
|
|
1346
|
+
// firmware: Build.ID, a dot, Build.VERSION.INCREMENTAL. Samsung's incremental
|
|
1347
|
+
// is the model without "SM-", region XX, update type U, bootloader revision,
|
|
1348
|
+
// major release, year (X = 2024) and month letters and a build digit; other
|
|
1349
|
+
// vendors are given a numeric one. Both belong to one install, so they come
|
|
1350
|
+
// from the session identity like pid and stay put across /code and /register.
|
|
1351
|
+
function getInstallIdentity(store, device, packageName) {
|
|
1352
|
+
const seed = (store.identityId && Buffer.isBuffer(store.identityId))
|
|
1353
|
+
? store.identityId
|
|
1354
|
+
: Buffer.from(String(store.phoneNumber || ''), 'utf8');
|
|
1355
|
+
const h = (label) => crypto.createHash('sha256').update(seed).update(label).digest();
|
|
1356
|
+
const tag = (label) => h(label).subarray(0, 16).toString('base64')
|
|
1357
|
+
.replace(/\+/g, '-').replace(/\//g, '_');
|
|
1358
|
+
|
|
1359
|
+
const sourceDir = '/data/app/~~' + tag('apkdir') + '/' +
|
|
1360
|
+
packageName + '-' + tag('pkgdir') + '/base.apk';
|
|
1361
|
+
|
|
1362
|
+
let incremental = device && device.osBuildIncremental;
|
|
1363
|
+
if (!incremental) {
|
|
1364
|
+
const f = h('firmware');
|
|
1365
|
+
const model = String((device && device.modelId) || '');
|
|
1366
|
+
if (/^SM-/i.test(model)) {
|
|
1367
|
+
incremental = model.replace(/^SM-/i, '').toUpperCase() + 'XXU' +
|
|
1368
|
+
String(1 + f[0] % 3) +
|
|
1369
|
+
'ABC'[f[1] % 3] +
|
|
1370
|
+
'X' +
|
|
1371
|
+
'ABCDEFGHIJKL'[f[2] % 12] +
|
|
1372
|
+
String(1 + f[3] % 9);
|
|
1373
|
+
} else {
|
|
1374
|
+
incremental = String(10000000 + f.readUInt32BE(4) % 90000000);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const buildId = (device && device.osBuildNumber) || 'UP1A.231005.007';
|
|
1378
|
+
|
|
1379
|
+
return { did: buildId + '.' + incremental, sourceDir };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Whether the request describes a phone with no SIM in it. The operator code
|
|
1383
|
+
// is the tell: a real mcc means a card is present, a missing or all-zero mcc
|
|
1384
|
+
// means none. Every SIM-shaped field (is_sim_absent here, sim_type/simnum and
|
|
1385
|
+
// the operator fields in getRequestVerificationCodeParameters) reads it off
|
|
1386
|
+
// this one test, so a request never says a SIM is both present and absent.
|
|
1387
|
+
function _simAbsent(meta) {
|
|
1348
1388
|
const mcc = meta && meta.mcc != null ? String(meta.mcc).trim() : '';
|
|
1349
|
-
|
|
1350
|
-
|
|
1389
|
+
return !/^\d+$/.test(mcc) || /^0+$/.test(mcc);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function buildClientMetrics(attempt, meta) {
|
|
1393
|
+
const simAbsent = _simAbsent(meta);
|
|
1394
|
+
const json = '{"attempts":' + (attempt == null ? 1 : attempt)
|
|
1351
1395
|
+ ',"app_campaign_download_source":"google-play|unknown"'
|
|
1352
1396
|
+ ',"is_sim_absent":' + (simAbsent ? 'true' : 'false') + '}';
|
|
1353
1397
|
return encodeURIComponent(json);
|
|
@@ -1382,20 +1426,30 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1382
1426
|
// otherwise gets the request answered with an SMS — or with no route at
|
|
1383
1427
|
// all. They stay at their headless-safe values for every other method.
|
|
1384
1428
|
const wantsFlash = method === 'flash';
|
|
1429
|
+
// One SIM story across every field. The operator codes decide it: a real
|
|
1430
|
+
// mcc means a SIM is in the phone, so sim_type and simnum say "present" and
|
|
1431
|
+
// the four operator fields carry the home network. A missing or all-zero
|
|
1432
|
+
// mcc is a phone with no SIM, which reports "000" in all four and both SIM
|
|
1433
|
+
// flags at 0 — the shape a device on Wi-Fi without a card actually sends.
|
|
1434
|
+
// buildClientMetrics reads is_sim_absent off the same test, so the request
|
|
1435
|
+
// never says a SIM is both there and not.
|
|
1436
|
+
const simAbsent = _simAbsent(meta);
|
|
1437
|
+
const simMcc = simAbsent ? '000' : meta.mcc;
|
|
1438
|
+
const simMnc = simAbsent ? '000' : meta.mnc;
|
|
1385
1439
|
return [
|
|
1386
1440
|
'method', method,
|
|
1387
|
-
'sim_mcc',
|
|
1388
|
-
'sim_mnc',
|
|
1441
|
+
'sim_mcc', simMcc,
|
|
1442
|
+
'sim_mnc', simMnc,
|
|
1389
1443
|
'reason', '',
|
|
1390
|
-
'mcc',
|
|
1391
|
-
'mnc',
|
|
1444
|
+
'mcc', simMcc,
|
|
1445
|
+
'mnc', simMnc,
|
|
1392
1446
|
'feo2_query_status', 'success_get_client_capabilities',
|
|
1393
1447
|
'db', '1',
|
|
1394
|
-
'sim_type', '1',
|
|
1448
|
+
'sim_type', simAbsent ? '0' : '1',
|
|
1395
1449
|
'recaptcha', '%7B%22stage%22%3A%22ABPROP_DISABLED%22%7D',
|
|
1396
1450
|
'network_radio_type', '1',
|
|
1397
1451
|
'prefer_sms_over_flash', wantsFlash ? 'true' : 'false',
|
|
1398
|
-
'simnum', '0',
|
|
1452
|
+
'simnum', simAbsent ? '0' : '1',
|
|
1399
1453
|
'airplane_mode_type', '0',
|
|
1400
1454
|
'client_metrics', buildClientMetrics(attempt, meta),
|
|
1401
1455
|
'mistyped', '7',
|
|
@@ -1431,6 +1485,31 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1431
1485
|
];
|
|
1432
1486
|
}
|
|
1433
1487
|
|
|
1488
|
+
// ---------- /register device parameters (Android) ----------
|
|
1489
|
+
//
|
|
1490
|
+
// The Android app sends its device description again on /register, with the
|
|
1491
|
+
// values it sent on /code, so they are taken from the /code list rather than
|
|
1492
|
+
// restated. client_metrics is rebuilt: its attempt counter restarts at 0 on
|
|
1493
|
+
// this screen.
|
|
1494
|
+
const REGISTER_DEVICE_FIELDS = new Set([
|
|
1495
|
+
'backup_token', 'sim_mnc', 'device_ram', 'db', 'network_radio_type', 'pid',
|
|
1496
|
+
'cellular_strength', 'hasinrc', 'aid', 'mcc', 'mnc', 'sim_mcc',
|
|
1497
|
+
'roaming_type', 'mistyped', 'airplane_mode_type', 'sim_type', 'simnum',
|
|
1498
|
+
'recaptcha'
|
|
1499
|
+
]);
|
|
1500
|
+
|
|
1501
|
+
function getRegisterDeviceParameters(store, meta, device) {
|
|
1502
|
+
if (!device || device.os !== 'android') return [];
|
|
1503
|
+
const code = getRequestVerificationCodeParameters(
|
|
1504
|
+
store, store.codeMethod || 'sms', meta, device, 0);
|
|
1505
|
+
const out = [];
|
|
1506
|
+
for (let i = 0; i < code.length; i += 2) {
|
|
1507
|
+
if (REGISTER_DEVICE_FIELDS.has(code[i])) out.push(code[i], code[i + 1]);
|
|
1508
|
+
}
|
|
1509
|
+
out.push('client_metrics', buildClientMetrics(0, meta));
|
|
1510
|
+
return out;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1434
1513
|
// ---------- Payload ----------
|
|
1435
1514
|
|
|
1436
1515
|
// base64url WITH padding — matches Java's Base64.getUrlEncoder().encodeToString()
|
|
@@ -1537,7 +1616,19 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
1537
1616
|
// were wired up.
|
|
1538
1617
|
const pushToken = await pushClientFor(device).getPushToken(store, device);
|
|
1539
1618
|
|
|
1540
|
-
const
|
|
1619
|
+
const authKeyRaw = stripKeyPrefix(store.noiseKeyPair.public);
|
|
1620
|
+
const apkMaterial = device.os === 'android' ? tryLoadAndroidMaterial(device) : null;
|
|
1621
|
+
// One _ga per session: the app sends the same one on /code and /register.
|
|
1622
|
+
if (device.os === 'android' && !store._gaToken) {
|
|
1623
|
+
store._gaToken = attestation.generateGaToken(authKeyRaw);
|
|
1624
|
+
}
|
|
1625
|
+
const attestFields = await attestation.attestationFields(device, nonceB64, {
|
|
1626
|
+
pushToken,
|
|
1627
|
+
authKeyRaw,
|
|
1628
|
+
apkMaterial,
|
|
1629
|
+
install: apkMaterial ? getInstallIdentity(store, device, apkMaterial.packageName) : null,
|
|
1630
|
+
ga: store._gaToken
|
|
1631
|
+
});
|
|
1541
1632
|
|
|
1542
1633
|
return buildForm([
|
|
1543
1634
|
'cc', cc,
|
|
@@ -2403,14 +2494,17 @@ async function verifyCode(store, code, opts) {
|
|
|
2403
2494
|
const screen = currentVerifyScreen(store);
|
|
2404
2495
|
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
2405
2496
|
|
|
2406
|
-
|
|
2497
|
+
// The Android app sends the setup token on /code only; iOS keeps it here.
|
|
2498
|
+
const registerMeta = getCountryMeta(parsePhone(store.phoneNumber).cc, store);
|
|
2499
|
+
const result = await sendRequest('/register', store, waVersion, _device.os !== 'android', [
|
|
2407
2500
|
'code', normalized,
|
|
2408
2501
|
'sim_operator_name', '',
|
|
2409
2502
|
'passkey_login_status', 'not_attempted',
|
|
2410
2503
|
'network_operator_name', '',
|
|
2411
2504
|
'entered', '1',
|
|
2412
2505
|
'has_play_store', 'true',
|
|
2413
|
-
'entrypoint', 'suma'
|
|
2506
|
+
'entrypoint', 'suma',
|
|
2507
|
+
...getRegisterDeviceParameters(store, registerMeta, _device)
|
|
2414
2508
|
]);
|
|
2415
2509
|
|
|
2416
2510
|
const status = result.status;
|
|
@@ -2504,7 +2598,7 @@ module.exports._verify = {
|
|
|
2504
2598
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
|
2505
2599
|
normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint,
|
|
2506
2600
|
flashCodeFromCallerId, flashCodeLength, applyFlashFilter, codeForSubmission,
|
|
2507
|
-
getRequestVerificationCodeParameters
|
|
2601
|
+
getRequestVerificationCodeParameters, getRegisterDeviceParameters, getInstallIdentity
|
|
2508
2602
|
};
|
|
2509
2603
|
|
|
2510
2604
|
// 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.26",
|
|
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",
|