whalibmob 5.12.9 → 5.12.11
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/.env.example +17 -2
- package/README.md +104 -1
- package/cli.js +73 -1
- package/lib/AndroidApk.js +413 -0
- package/lib/Client.js +54 -5
- package/lib/Registration.js +113 -23
- package/lib/constants.js +6 -3
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -53,13 +53,28 @@
|
|
|
53
53
|
# ─── WhatsApp version ────────────────────────────────────────────────────────
|
|
54
54
|
# Pin the WhatsApp client version. When set, the live version lookup is skipped
|
|
55
55
|
# entirely. Leave unset to fetch the current version automatically (recommended).
|
|
56
|
+
#
|
|
57
|
+
# On Android this is normally not needed either way: the version is taken from
|
|
58
|
+
# the APK the token material came from, since the token is signed over that
|
|
59
|
+
# build. Setting this still overrides it.
|
|
56
60
|
# WA_VERSION=2.24.10.75
|
|
57
61
|
|
|
58
|
-
# Override the static registration token.
|
|
59
|
-
#
|
|
62
|
+
# Override the static registration token. iOS only — Android has no static token
|
|
63
|
+
# and signs its own out of the APK (see below). Leave unset to use the built-in
|
|
64
|
+
# one; only change this if you know what you are doing.
|
|
60
65
|
# WA_STATIC_TOKEN=
|
|
61
66
|
|
|
62
67
|
|
|
68
|
+
# ─── Android registration token material ─────────────────────────────────────
|
|
69
|
+
# Registering with WA_OS=android needs material read out of a WhatsApp APK once:
|
|
70
|
+
#
|
|
71
|
+
# wa apk-material base.apk [split_config.xxhdpi.apk ...]
|
|
72
|
+
#
|
|
73
|
+
# It is written to <session dir>/android-apk-material.json by default. Set this
|
|
74
|
+
# to keep it somewhere else. Registering as iOS needs none of it.
|
|
75
|
+
# WA_ANDROID_APK_MATERIAL=/path/to/android-apk-material.json
|
|
76
|
+
|
|
77
|
+
|
|
63
78
|
# ─── Proxy ───────────────────────────────────────────────────────────────────
|
|
64
79
|
# Route registration traffic through a SOCKS proxy. Requires the `socks`
|
|
65
80
|
# package: npm install socks
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
CONTACT ME ON TELEGRAM IF YOU WANT TO WORK WITH ME AND IF YOU HAVE PROBLEM WITH WHALIBMOB : @brtyu545
|
|
11
11
|
|
|
12
|
-
If you want News about whalibmob enter
|
|
12
|
+
If you want News about whalibmob enter this whalibmob channel: https://t.me/+sHN4MDCyB7U5OWY0
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
|
|
@@ -120,6 +120,7 @@ npm install -g whalibmob
|
|
|
120
120
|
- [Library API](#library-api)
|
|
121
121
|
- [Connecting Account](#connecting-account)
|
|
122
122
|
- [Register a New Number](#register-a-new-number)
|
|
123
|
+
- [Registering as Android](#registering-as-android)
|
|
123
124
|
- [Device Attestation with Frida (optional)](#device-attestation-with-frida-optional)
|
|
124
125
|
- [Connect](#connect)
|
|
125
126
|
- [Linking to an Existing Account (Pairing Code)](#linking-to-an-existing-account-pairing-code)
|
|
@@ -273,6 +274,10 @@ wa version
|
|
|
273
274
|
|
|
274
275
|
Registration is a one-time process. You need a phone number that can receive an SMS or voice call. **Use a dedicated number** — do not use a number already active on a real WhatsApp device.
|
|
275
276
|
|
|
277
|
+
> [!IMPORTANT]
|
|
278
|
+
> Registering as **Android** (`WA_OS=android`) needs one extra step first — see
|
|
279
|
+
> [Registering as Android](#registering-as-android). Registering as iOS, the default, does not.
|
|
280
|
+
|
|
276
281
|
**Step 1 — request a verification code**
|
|
277
282
|
|
|
278
283
|
```sh
|
|
@@ -678,6 +683,11 @@ wa> /about Available 24/7 for support
|
|
|
678
683
|
about updated
|
|
679
684
|
```
|
|
680
685
|
|
|
686
|
+
`about updated` now means the about was read back from the server and matches
|
|
687
|
+
what you typed. When the server accepts the stanza but still reports something
|
|
688
|
+
else, it says so instead — and if the about is stored but hidden, it names the
|
|
689
|
+
privacy setting that is hiding it.
|
|
690
|
+
|
|
681
691
|
#### CLI Change Profile Picture
|
|
682
692
|
|
|
683
693
|
Reads the image from disk, crops it square, scales it to 640×640 and uploads it
|
|
@@ -1539,6 +1549,80 @@ Both are optional and both keep working when omitted — you get an error naming
|
|
|
1539
1549
|
> [!NOTE]
|
|
1540
1550
|
> Registration reports the screens it passes through to WhatsApp's `/client_log`, the way the phone clients do — a client that registers in total silence does something no real installation does. It is fire-and-forget and every failure is swallowed, so it can never take a registration down. Set `WA_FUNNEL_LOG=0` to send none of it.
|
|
1541
1551
|
|
|
1552
|
+
### Registering as Android
|
|
1553
|
+
|
|
1554
|
+
The registration token is computed differently on each platform, and only iOS
|
|
1555
|
+
derives it from a constant. The Android client signs it with material out of its
|
|
1556
|
+
own APK:
|
|
1557
|
+
|
|
1558
|
+
```
|
|
1559
|
+
key = PBKDF2-HMAC-SHA1(password = "com.whatsapp" + about_logo.png,
|
|
1560
|
+
salt = fixed, iterations = 128, length = 64)
|
|
1561
|
+
token = urlencode(base64(HMAC-SHA1(key, signing certificates
|
|
1562
|
+
+ MD5(classes.dex)
|
|
1563
|
+
+ national number)))
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
None of that can be derived, so it has to be read out of a real APK once. Sending
|
|
1567
|
+
the iOS-shaped token as Android is answered with `{"reason":"bad_token"}`, and no
|
|
1568
|
+
value in `WA_STATIC_TOKEN` changes that — the constant is not what is wrong, the
|
|
1569
|
+
formula is. `WA_STATIC_TOKEN` applies to iOS only.
|
|
1570
|
+
|
|
1571
|
+
**Get an APK.** Pull it off any phone or emulator that has WhatsApp installed:
|
|
1572
|
+
|
|
1573
|
+
```sh
|
|
1574
|
+
adb shell pm path com.whatsapp
|
|
1575
|
+
# package:/data/app/~~xyz==/com.whatsapp-abc==/base.apk
|
|
1576
|
+
# package:/data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk
|
|
1577
|
+
adb pull /data/app/~~xyz==/com.whatsapp-abc==/base.apk
|
|
1578
|
+
adb pull /data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
Recent releases ship as an App Bundle, so `about_logo.png` often lives in a
|
|
1582
|
+
density split rather than in `base.apk`. Pull the `split_config.*dpi.apk` files
|
|
1583
|
+
too and pass them along — only splits whose name ends in `dpi` are searched.
|
|
1584
|
+
|
|
1585
|
+
**Read the material out of it:**
|
|
1586
|
+
|
|
1587
|
+
```sh
|
|
1588
|
+
wa apk-material base.apk split_config.xxhdpi.apk
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
```
|
|
1592
|
+
reading base.apk...
|
|
1593
|
+
package com.whatsapp
|
|
1594
|
+
version 2.26.25.80 (code 260908001)
|
|
1595
|
+
certificates 1
|
|
1596
|
+
classes.dex md5 5c71f02aaad331e16e436bfa83ea3c5b
|
|
1597
|
+
written to /home/you/.waSession/android-apk-material.json
|
|
1598
|
+
```
|
|
1599
|
+
|
|
1600
|
+
Only the derived pieces are kept — the APK is never needed again. Registration
|
|
1601
|
+
picks the file up on its own from then on. `--out <file>` writes it elsewhere,
|
|
1602
|
+
and `WA_ANDROID_APK_MATERIAL` points at it if you keep it somewhere else.
|
|
1603
|
+
|
|
1604
|
+
**The version comes from the APK too.** It is read out of the binary
|
|
1605
|
+
`AndroidManifest.xml` and announced from then on, instead of the version the Play
|
|
1606
|
+
Store currently lists. The token is signed over *this* build's `classes.dex`, so
|
|
1607
|
+
announcing any other version describes a build the token does not belong to.
|
|
1608
|
+
`WA_VERSION` still overrides everything if you need it to; on a manifest with no
|
|
1609
|
+
`versionName`, pass `--version <x.y.z.w>`.
|
|
1610
|
+
|
|
1611
|
+
**Re-run it when you update the APK.** `classes.dex` changes with every WhatsApp
|
|
1612
|
+
release, and its MD5 is signed into the token.
|
|
1613
|
+
|
|
1614
|
+
Programmatically:
|
|
1615
|
+
|
|
1616
|
+
```js
|
|
1617
|
+
const { extractMaterial, computeToken, materialToJson } = require('whalibmob/lib/AndroidApk')
|
|
1618
|
+
|
|
1619
|
+
const material = extractMaterial(
|
|
1620
|
+
fs.readFileSync('base.apk'),
|
|
1621
|
+
[{ name: 'split_config.xxhdpi.apk', data: fs.readFileSync('split_config.xxhdpi.apk') }]
|
|
1622
|
+
)
|
|
1623
|
+
fs.writeFileSync('android-apk-material.json', JSON.stringify(materialToJson(material)))
|
|
1624
|
+
```
|
|
1625
|
+
|
|
1542
1626
|
### Device Attestation with Frida (optional)
|
|
1543
1627
|
|
|
1544
1628
|
WhatsApp's registration servers score every `/code` and `/register` request on how
|
|
@@ -3402,6 +3486,9 @@ const results = await client.hasWhatsapp(['919634847671', '12345678901'])
|
|
|
3402
3486
|
```js
|
|
3403
3487
|
const about = await client.queryAbout('919634847671@s.whatsapp.net')
|
|
3404
3488
|
console.log(about)
|
|
3489
|
+
|
|
3490
|
+
// your own, asked the same way
|
|
3491
|
+
console.log(await client.queryOwnAbout())
|
|
3405
3492
|
```
|
|
3406
3493
|
|
|
3407
3494
|
### Fetch Profile Picture
|
|
@@ -3435,6 +3522,22 @@ client.changeName('My Bot')
|
|
|
3435
3522
|
|
|
3436
3523
|
```js
|
|
3437
3524
|
await client.changeAbout('Available 24/7')
|
|
3525
|
+
|
|
3526
|
+
// what the server actually has now — the set IQ is answered before the new
|
|
3527
|
+
// text has to be visible anywhere, so this is the part worth reading
|
|
3528
|
+
const stored = await client.queryOwnAbout()
|
|
3529
|
+
```
|
|
3530
|
+
|
|
3531
|
+
`changeAbout` throws when the server refuses the change or never answers; it
|
|
3532
|
+
used to hand back the reply unread, so a refusal looked exactly like a success.
|
|
3533
|
+
An about is limited to 139 characters — a longer one is dropped rather than
|
|
3534
|
+
refused, and that too is now a thrown error instead of silence.
|
|
3535
|
+
|
|
3536
|
+
An about the server stores and nobody can see is a privacy setting rather than a
|
|
3537
|
+
failed write. The category that governs who may read it is `status`:
|
|
3538
|
+
|
|
3539
|
+
```js
|
|
3540
|
+
await client.changePrivacySetting('status', 'all')
|
|
3438
3541
|
```
|
|
3439
3542
|
|
|
3440
3543
|
### Change Profile Picture
|
package/cli.js
CHANGED
|
@@ -1411,7 +1411,26 @@ async function handleLine(line) {
|
|
|
1411
1411
|
const text = p.slice(1).join(' ');
|
|
1412
1412
|
if (!text) { fail('usage: /about <text>'); break; }
|
|
1413
1413
|
await _client.changeAbout(text);
|
|
1414
|
-
|
|
1414
|
+
|
|
1415
|
+
// The server answers the set IQ before the new about has to be visible
|
|
1416
|
+
// anywhere, so "result" is not the same as "changed" — this used to
|
|
1417
|
+
// report success on the strength of that empty reply alone. Read it
|
|
1418
|
+
// back and say what is actually stored.
|
|
1419
|
+
let stored = null;
|
|
1420
|
+
try { stored = await _client.queryOwnAbout(); } catch (_) {}
|
|
1421
|
+
if (stored === text) out('about updated');
|
|
1422
|
+
else if (stored) out('sent, but the server still reports: ' + stored);
|
|
1423
|
+
else out('sent, and the server accepted it — it reports no about for this number yet');
|
|
1424
|
+
|
|
1425
|
+
// An about that is stored and still nowhere to be seen is a privacy
|
|
1426
|
+
// setting rather than a failed write: on the wire the category that
|
|
1427
|
+
// governs who may read it is "status".
|
|
1428
|
+
try {
|
|
1429
|
+
const priv = await _client.queryPrivacySettings({ force: true });
|
|
1430
|
+
if (priv && priv.status && priv.status !== 'all') {
|
|
1431
|
+
out(' about visibility is "' + priv.status + '" — /privacy status all shows it to everyone');
|
|
1432
|
+
}
|
|
1433
|
+
} catch (_) {}
|
|
1415
1434
|
break;
|
|
1416
1435
|
}
|
|
1417
1436
|
|
|
@@ -2309,10 +2328,12 @@ usage:
|
|
|
2309
2328
|
wa registration --request-code <phone> request SMS code
|
|
2310
2329
|
wa registration --register <phone> --code <code>
|
|
2311
2330
|
wa registration --check <phone>
|
|
2331
|
+
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2312
2332
|
wa version
|
|
2313
2333
|
|
|
2314
2334
|
options:
|
|
2315
2335
|
--session <dir> session directory (default: ~/.waSession)
|
|
2336
|
+
--out <file> where apk-material writes (default: <session dir>/android-apk-material.json)
|
|
2316
2337
|
--sms connect by registering this number over SMS
|
|
2317
2338
|
--pair connect by linking to an existing account (8-digit code)
|
|
2318
2339
|
--method sms | voice | wa_old | email (default: sms)
|
|
@@ -2415,6 +2436,57 @@ async function main() {
|
|
|
2415
2436
|
return;
|
|
2416
2437
|
}
|
|
2417
2438
|
|
|
2439
|
+
// Read the Android registration token material out of a WhatsApp APK.
|
|
2440
|
+
//
|
|
2441
|
+
// Registering as Android signs its token with the APK's own signing
|
|
2442
|
+
// certificates, the MD5 of its classes.dex, and a key derived from
|
|
2443
|
+
// about_logo.png — none of which can be derived, so they are read out of a
|
|
2444
|
+
// real APK once and kept. Registering as iOS needs none of it.
|
|
2445
|
+
if (cmd === 'apk-material') {
|
|
2446
|
+
// parseArgs puts the first non-flag argument in sub, the rest in pos.
|
|
2447
|
+
const apks = [sub, ...pos].filter(Boolean);
|
|
2448
|
+
if (!apks.length) {
|
|
2449
|
+
fail('usage: wa apk-material <base.apk> [split.apk ...]');
|
|
2450
|
+
process.exit(1);
|
|
2451
|
+
}
|
|
2452
|
+
const outFile = flags.out ||
|
|
2453
|
+
process.env.WA_ANDROID_APK_MATERIAL ||
|
|
2454
|
+
path.join(_sessDir, 'android-apk-material.json');
|
|
2455
|
+
try {
|
|
2456
|
+
const AndroidApk = require('./lib/AndroidApk');
|
|
2457
|
+
const [basePath, ...splitPaths] = apks;
|
|
2458
|
+
out('reading ' + basePath + '...');
|
|
2459
|
+
const material = AndroidApk.extractMaterial(
|
|
2460
|
+
fs.readFileSync(basePath),
|
|
2461
|
+
splitPaths.map(p => ({ name: path.basename(p), data: fs.readFileSync(p) }))
|
|
2462
|
+
);
|
|
2463
|
+
if (flags.version) material.apkVersion = String(flags.version);
|
|
2464
|
+
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2465
|
+
fs.writeFileSync(outFile, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
|
|
2466
|
+
kv('package', material.packageName);
|
|
2467
|
+
kv('version', material.apkVersion
|
|
2468
|
+
? material.apkVersion + (material.apkVersionCode ? ' (code ' + material.apkVersionCode + ')' : '')
|
|
2469
|
+
: '(not in the manifest — pass --version <x.y.z.w>)');
|
|
2470
|
+
kv('certificates', String(material.certificates.length));
|
|
2471
|
+
kv('classes.dex md5', material.classesDexMd5.toString('hex'));
|
|
2472
|
+
kv('written to', outFile);
|
|
2473
|
+
out('');
|
|
2474
|
+
if (material.apkVersion) {
|
|
2475
|
+
out(' Registration will announce ' + material.apkVersion + ' from now on, because the');
|
|
2476
|
+
out(' token is signed over this APK — the live Play Store version would name a');
|
|
2477
|
+
out(' different build. WA_VERSION still overrides it.');
|
|
2478
|
+
} else {
|
|
2479
|
+
out(' The manifest carries no versionName, so the live Play Store version will be');
|
|
2480
|
+
out(' announced. If that does not match this APK, pass --version or set WA_VERSION.');
|
|
2481
|
+
}
|
|
2482
|
+
out(' Re-run this when you update the APK — classes.dex changes every release.');
|
|
2483
|
+
} catch (e) {
|
|
2484
|
+
fail(e.message);
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
}
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2418
2490
|
if (cmd === 'registration' || cmd === 'reg') {
|
|
2419
2491
|
const rawPhone =
|
|
2420
2492
|
flags['request-code'] !== undefined && flags['request-code'] !== true ? String(flags['request-code']) :
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Android registration token.
|
|
4
|
+
//
|
|
5
|
+
// iOS derives its token from a static string and the client version, and that
|
|
6
|
+
// is the algorithm this library used for both platforms — with a different
|
|
7
|
+
// constant substituted for Android. There is no such constant: the Android
|
|
8
|
+
// client signs the token with material taken out of its own APK, and no value
|
|
9
|
+
// put in WA_STATIC_TOKEN can stand in for it. Sending the iOS-shaped token as
|
|
10
|
+
// Android is answered with {"reason":"bad_token"}.
|
|
11
|
+
//
|
|
12
|
+
// What the native client actually computes, and what this file reproduces from
|
|
13
|
+
// Cobalt's WhatsAppAndroidClientInfo:
|
|
14
|
+
//
|
|
15
|
+
// key = PBKDF2-HMAC-SHA1(password = packageName || about_logo.png,
|
|
16
|
+
// salt = ANDROID_SALT, iterations = 128, dkLen = 64)
|
|
17
|
+
// mac = HMAC-SHA1(key) over each APK signing certificate in order,
|
|
18
|
+
// then MD5(classes.dex), then the national number as ASCII
|
|
19
|
+
// token = urlencode(base64(mac))
|
|
20
|
+
//
|
|
21
|
+
// The three pieces of material live in the APK, so they cannot be derived —
|
|
22
|
+
// they have to be read out of one. extractMaterial does that; the CLI's
|
|
23
|
+
// apk-material command drives it and writes the result where computeToken
|
|
24
|
+
// looks for it.
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
const zlib = require('zlib');
|
|
28
|
+
|
|
29
|
+
// Reverse engineered from the Android binary and identical for the consumer and
|
|
30
|
+
// business builds. Cobalt notes it has been stable across many releases.
|
|
31
|
+
const ANDROID_SALT = Buffer.from(
|
|
32
|
+
'PkTwKSZqUfAUyR0rPQ8hYJ0wNsQQ3dW1+3SCnyTXIfEAxxS75FwkDf47wNv/c8pP' +
|
|
33
|
+
'3p0GXKR6OOQmhyERwx74fw1RYSU10I4r1gyBVDbRJ40pidjM41G1I1oN', 'base64');
|
|
34
|
+
|
|
35
|
+
// Where about_logo.png has shipped across releases. The base APK is searched in
|
|
36
|
+
// this order first; App Bundle releases moved density-qualified drawables into
|
|
37
|
+
// per-density splits, which is why the splits are searched after it.
|
|
38
|
+
const ABOUT_LOGO_PATHS = [
|
|
39
|
+
'res/drawable-hdpi/about_logo.png',
|
|
40
|
+
'res/drawable-hdpi-v4/about_logo.png',
|
|
41
|
+
'res/drawable-xxhdpi-v4/about_logo.png'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const PERSONAL_PACKAGE = 'com.whatsapp';
|
|
45
|
+
const BUSINESS_PACKAGE = 'com.whatsapp.w4b';
|
|
46
|
+
|
|
47
|
+
const PBKDF2_ITERATIONS = 128;
|
|
48
|
+
const PBKDF2_KEY_SIZE = 64;
|
|
49
|
+
|
|
50
|
+
// ─── ZIP ──────────────────────────────────────────────────────────────────────
|
|
51
|
+
//
|
|
52
|
+
// An APK is a ZIP. Only two things are needed from it — a named entry's bytes
|
|
53
|
+
// and the list of entry names — so this reads the central directory and inflates
|
|
54
|
+
// on demand rather than pulling in an archive library.
|
|
55
|
+
|
|
56
|
+
function _findEndOfCentralDirectory(buf) {
|
|
57
|
+
// The record is 22 bytes plus a comment of up to 64 KiB, so it sits within the
|
|
58
|
+
// last 64 KiB + 22. Scanning backwards finds the real one before any earlier
|
|
59
|
+
// byte sequence that happens to look like the signature.
|
|
60
|
+
const min = Math.max(0, buf.length - (0xffff + 22));
|
|
61
|
+
for (let i = buf.length - 22; i >= min; i--) {
|
|
62
|
+
if (buf.readUInt32LE(i) === 0x06054b50) return i;
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// name → { method, compressedSize, size, localHeaderOffset }, in central
|
|
68
|
+
// directory order.
|
|
69
|
+
function readZipDirectory(buf) {
|
|
70
|
+
const eocd = _findEndOfCentralDirectory(buf);
|
|
71
|
+
if (eocd < 0) throw new Error('not a zip archive: no end-of-central-directory record');
|
|
72
|
+
|
|
73
|
+
let count = buf.readUInt16LE(eocd + 10);
|
|
74
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
75
|
+
|
|
76
|
+
// ZIP64: the 32-bit fields are saturated and the real ones live in the ZIP64
|
|
77
|
+
// record the locator points at. A WhatsApp APK is far from needing it, but an
|
|
78
|
+
// archive that does would otherwise be read as garbage rather than refused.
|
|
79
|
+
if (count === 0xffff || offset === 0xffffffff) {
|
|
80
|
+
const loc = eocd - 20;
|
|
81
|
+
if (loc < 0 || buf.readUInt32LE(loc) !== 0x07064b50) {
|
|
82
|
+
throw new Error('zip needs ZIP64 but carries no ZIP64 locator');
|
|
83
|
+
}
|
|
84
|
+
const z64 = Number(buf.readBigUInt64LE(loc + 8));
|
|
85
|
+
if (buf.readUInt32LE(z64) !== 0x06064b50) throw new Error('bad ZIP64 end-of-central-directory');
|
|
86
|
+
count = Number(buf.readBigUInt64LE(z64 + 32));
|
|
87
|
+
offset = Number(buf.readBigUInt64LE(z64 + 48));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entries = new Map();
|
|
91
|
+
let p = offset;
|
|
92
|
+
for (let i = 0; i < count; i++) {
|
|
93
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) break;
|
|
94
|
+
const method = buf.readUInt16LE(p + 10);
|
|
95
|
+
const compressedSize = buf.readUInt32LE(p + 20);
|
|
96
|
+
const size = buf.readUInt32LE(p + 24);
|
|
97
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
98
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
99
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
100
|
+
const localHeaderOffset = buf.readUInt32LE(p + 42);
|
|
101
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
102
|
+
entries.set(name, { method, compressedSize, size, localHeaderOffset });
|
|
103
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readZipEntry(buf, entries, name) {
|
|
109
|
+
const e = entries.get(name);
|
|
110
|
+
if (!e) return null;
|
|
111
|
+
const lh = e.localHeaderOffset;
|
|
112
|
+
if (buf.readUInt32LE(lh) !== 0x04034b50) throw new Error('bad local header for ' + name);
|
|
113
|
+
// The local header's extra field is allowed to differ in length from the
|
|
114
|
+
// central directory's, so the data offset is computed from the local one.
|
|
115
|
+
const nameLen = buf.readUInt16LE(lh + 26);
|
|
116
|
+
const extraLen = buf.readUInt16LE(lh + 28);
|
|
117
|
+
const start = lh + 30 + nameLen + extraLen;
|
|
118
|
+
const raw = buf.slice(start, start + e.compressedSize);
|
|
119
|
+
if (e.method === 0) return raw;
|
|
120
|
+
if (e.method === 8) return zlib.inflateRawSync(raw);
|
|
121
|
+
throw new Error('unsupported zip compression method ' + e.method + ' for ' + name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── DER / PKCS#7 ─────────────────────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// The signing certificates come out of the v1 (JAR) signature block, which is
|
|
127
|
+
// what Cobalt reads — apk-parser's getApkSingers() is the v1 signer list. Only
|
|
128
|
+
// enough DER is parsed to walk into SignedData and lift each certificate out
|
|
129
|
+
// whole; nothing here validates a signature.
|
|
130
|
+
|
|
131
|
+
function _readTlv(buf, pos) {
|
|
132
|
+
const tag = buf[pos];
|
|
133
|
+
let p = pos + 1;
|
|
134
|
+
let len = buf[p++];
|
|
135
|
+
if (len & 0x80) {
|
|
136
|
+
const n = len & 0x7f;
|
|
137
|
+
if (n === 0) throw new Error('indefinite DER length');
|
|
138
|
+
len = 0;
|
|
139
|
+
for (let i = 0; i < n; i++) len = (len << 8) | buf[p++];
|
|
140
|
+
}
|
|
141
|
+
return { tag, headerLength: p - pos, length: len, contentStart: p, end: p + len };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Every certificate in a PKCS#7 SignedData, each as its own DER blob, in the
|
|
145
|
+
// order the structure carries them.
|
|
146
|
+
function certificatesFromPkcs7(der) {
|
|
147
|
+
const contentInfo = _readTlv(der, 0); // SEQUENCE
|
|
148
|
+
let p = contentInfo.contentStart;
|
|
149
|
+
const oid = _readTlv(der, p); // contentType OID
|
|
150
|
+
p = oid.end;
|
|
151
|
+
const explicit = _readTlv(der, p); // [0] EXPLICIT
|
|
152
|
+
const signedData = _readTlv(der, explicit.contentStart);
|
|
153
|
+
|
|
154
|
+
// SignedData ::= version, digestAlgorithms, contentInfo, [0] certificates …
|
|
155
|
+
let q = signedData.contentStart;
|
|
156
|
+
while (q < signedData.end) {
|
|
157
|
+
const field = _readTlv(der, q);
|
|
158
|
+
if (field.tag === 0xa0) { // [0] IMPLICIT certificates
|
|
159
|
+
const out = [];
|
|
160
|
+
let c = field.contentStart;
|
|
161
|
+
while (c < field.end) {
|
|
162
|
+
const cert = _readTlv(der, c);
|
|
163
|
+
if (cert.tag === 0x30) out.push(der.slice(c, cert.end));
|
|
164
|
+
c = cert.end;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
q = field.end;
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Binary AndroidManifest.xml ───────────────────────────────────────────────
|
|
174
|
+
//
|
|
175
|
+
// The manifest inside an APK is Android's binary XML, not text. Three things are
|
|
176
|
+
// read out of it — the package name, the version name and the version code —
|
|
177
|
+
// the same three Cobalt takes from its parsed manifest. The version name is the
|
|
178
|
+
// one that matters most: the token is signed over this APK's classes.dex, so the
|
|
179
|
+
// version announced to the server has to be this APK's, not whatever the Play
|
|
180
|
+
// Store currently lists.
|
|
181
|
+
//
|
|
182
|
+
// Chunk layout is AOSP's ResourceTypes.h. Only what is needed to reach the
|
|
183
|
+
// attributes of the first element is decoded.
|
|
184
|
+
|
|
185
|
+
const _RES_STRING_POOL_TYPE = 0x0001;
|
|
186
|
+
const _RES_XML_START_ELEMENT = 0x0102;
|
|
187
|
+
const _UTF8_FLAG = 0x0100;
|
|
188
|
+
const _TYPE_STRING = 0x03;
|
|
189
|
+
|
|
190
|
+
function _readStringPool(buf, pos) {
|
|
191
|
+
const size = buf.readUInt32LE(pos + 4);
|
|
192
|
+
const stringCount = buf.readUInt32LE(pos + 8);
|
|
193
|
+
const flags = buf.readUInt32LE(pos + 16);
|
|
194
|
+
const stringsStart = buf.readUInt32LE(pos + 20);
|
|
195
|
+
const utf8 = (flags & _UTF8_FLAG) !== 0;
|
|
196
|
+
|
|
197
|
+
const strings = [];
|
|
198
|
+
for (let i = 0; i < stringCount; i++) {
|
|
199
|
+
const offset = buf.readUInt32LE(pos + 28 + i * 4);
|
|
200
|
+
let p = pos + stringsStart + offset;
|
|
201
|
+
if (p >= pos + size) { strings.push(''); continue; }
|
|
202
|
+
if (utf8) {
|
|
203
|
+
// Two lengths, characters then bytes, each one or two bytes wide.
|
|
204
|
+
let charLen = buf[p++];
|
|
205
|
+
if (charLen & 0x80) charLen = ((charLen & 0x7f) << 8) | buf[p++];
|
|
206
|
+
let byteLen = buf[p++];
|
|
207
|
+
if (byteLen & 0x80) byteLen = ((byteLen & 0x7f) << 8) | buf[p++];
|
|
208
|
+
strings.push(buf.toString('utf8', p, p + byteLen));
|
|
209
|
+
} else {
|
|
210
|
+
let len = buf.readUInt16LE(p); p += 2;
|
|
211
|
+
if (len & 0x8000) { len = ((len & 0x7fff) << 16) | buf.readUInt16LE(p); p += 2; }
|
|
212
|
+
strings.push(buf.toString('utf16le', p, p + len * 2));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return strings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* { packageName, versionName, versionCode } out of a binary AndroidManifest.xml.
|
|
220
|
+
* Any field the manifest does not carry comes back null.
|
|
221
|
+
*/
|
|
222
|
+
function parseManifest(buf) {
|
|
223
|
+
const empty = { packageName: null, versionName: null, versionCode: null };
|
|
224
|
+
if (!buf || buf.length < 8) return empty;
|
|
225
|
+
|
|
226
|
+
let strings = null;
|
|
227
|
+
let pos = 8; // past the file's own chunk header
|
|
228
|
+
while (pos + 8 <= buf.length) {
|
|
229
|
+
const type = buf.readUInt16LE(pos);
|
|
230
|
+
const chunkSize = buf.readUInt32LE(pos + 4);
|
|
231
|
+
if (chunkSize <= 0 || pos + chunkSize > buf.length) break;
|
|
232
|
+
|
|
233
|
+
if (type === _RES_STRING_POOL_TYPE) {
|
|
234
|
+
strings = _readStringPool(buf, pos);
|
|
235
|
+
} else if (type === _RES_XML_START_ELEMENT && strings) {
|
|
236
|
+
// ResXMLTree_node (8 bytes) then ResXMLTree_attrExt.
|
|
237
|
+
const ext = pos + 16;
|
|
238
|
+
const nameIndex = buf.readUInt32LE(ext + 4);
|
|
239
|
+
const attributeStart = buf.readUInt16LE(ext + 8);
|
|
240
|
+
const attributeSize = buf.readUInt16LE(ext + 10);
|
|
241
|
+
const attributeCount = buf.readUInt16LE(ext + 12);
|
|
242
|
+
if (strings[nameIndex] !== 'manifest') { pos += chunkSize; continue; }
|
|
243
|
+
|
|
244
|
+
const out = Object.assign({}, empty);
|
|
245
|
+
for (let i = 0; i < attributeCount; i++) {
|
|
246
|
+
const a = ext + attributeStart + i * attributeSize;
|
|
247
|
+
const attrName = strings[buf.readUInt32LE(a + 4)];
|
|
248
|
+
const rawValue = buf.readUInt32LE(a + 8);
|
|
249
|
+
const dataType = buf[a + 15];
|
|
250
|
+
const data = buf.readUInt32LE(a + 16);
|
|
251
|
+
// A string attribute names its value in the pool twice — the raw source
|
|
252
|
+
// text and the typed value. Either will do; the raw one is absent when
|
|
253
|
+
// the manifest was written without it.
|
|
254
|
+
const asString = () =>
|
|
255
|
+
(rawValue !== 0xffffffff && strings[rawValue] !== undefined) ? strings[rawValue]
|
|
256
|
+
: (dataType === _TYPE_STRING ? strings[data] : String(data));
|
|
257
|
+
|
|
258
|
+
if (attrName === 'package') out.packageName = asString();
|
|
259
|
+
else if (attrName === 'versionName') out.versionName = asString();
|
|
260
|
+
else if (attrName === 'versionCode') out.versionCode = dataType === _TYPE_STRING
|
|
261
|
+
? parseInt(strings[data], 10) : data;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
pos += chunkSize;
|
|
266
|
+
}
|
|
267
|
+
return empty;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A split's own name, as Android files it: "config.xxhdpi" out of
|
|
271
|
+
// "base-config.xxhdpi.apk" or "split_config.xxhdpi.apk". Cobalt only searches
|
|
272
|
+
// splits whose name ends in "dpi", which is the density-qualified set.
|
|
273
|
+
function _isDensitySplit(fileName) {
|
|
274
|
+
const base = String(fileName).replace(/\.apk$/i, '');
|
|
275
|
+
return /dpi$/i.test(base);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read the token material out of a WhatsApp APK.
|
|
280
|
+
*
|
|
281
|
+
* base: Buffer with the base APK.
|
|
282
|
+
* splits: optional [{ name, data }] for the density splits, searched only when
|
|
283
|
+
* the base APK does not carry about_logo.png — which is the case for
|
|
284
|
+
* every App Bundle release.
|
|
285
|
+
*
|
|
286
|
+
* Returns { packageName, secretKey, classesDexMd5, certificates } with the
|
|
287
|
+
* three derived pieces as Buffers, ready for computeToken.
|
|
288
|
+
*/
|
|
289
|
+
function extractMaterial(base, splits) {
|
|
290
|
+
const entries = readZipDirectory(base);
|
|
291
|
+
|
|
292
|
+
let aboutLogo = null;
|
|
293
|
+
for (const p of ABOUT_LOGO_PATHS) {
|
|
294
|
+
aboutLogo = readZipEntry(base, entries, p);
|
|
295
|
+
if (aboutLogo) break;
|
|
296
|
+
}
|
|
297
|
+
if (!aboutLogo) {
|
|
298
|
+
for (const split of (splits || [])) {
|
|
299
|
+
if (!_isDensitySplit(split.name)) continue;
|
|
300
|
+
const sEntries = readZipDirectory(split.data);
|
|
301
|
+
for (const p of ABOUT_LOGO_PATHS) {
|
|
302
|
+
aboutLogo = readZipEntry(split.data, sEntries, p);
|
|
303
|
+
if (aboutLogo) break;
|
|
304
|
+
}
|
|
305
|
+
if (aboutLogo) break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!aboutLogo) {
|
|
309
|
+
throw new Error('about_logo.png is in neither the base APK nor any density split — ' +
|
|
310
|
+
'pass the split APKs alongside the base one (' + ABOUT_LOGO_PATHS.join(', ') + ')');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const classesDex = readZipEntry(base, entries, 'classes.dex');
|
|
314
|
+
if (!classesDex) throw new Error('classes.dex missing from the base APK');
|
|
315
|
+
|
|
316
|
+
const certificates = [];
|
|
317
|
+
for (const name of entries.keys()) {
|
|
318
|
+
if (!/^META-INF\/[^/]+\.(RSA|DSA|EC)$/i.test(name)) continue;
|
|
319
|
+
for (const cert of certificatesFromPkcs7(readZipEntry(base, entries, name))) {
|
|
320
|
+
certificates.push(cert);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!certificates.length) {
|
|
324
|
+
throw new Error('no v1 signing certificate in META-INF — this APK is not JAR-signed, ' +
|
|
325
|
+
'and the token is built from the v1 signature block');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const manifest = parseManifest(readZipEntry(base, entries, 'AndroidManifest.xml'));
|
|
329
|
+
const packageName = manifest.packageName || PERSONAL_PACKAGE;
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
packageName,
|
|
333
|
+
secretKey: deriveSecretKey(packageName, aboutLogo),
|
|
334
|
+
classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
|
|
335
|
+
certificates,
|
|
336
|
+
// The version this APK is. Announced instead of the live one, because the
|
|
337
|
+
// token is signed over this build's classes.dex.
|
|
338
|
+
apkVersion: manifest.versionName,
|
|
339
|
+
apkVersionCode: manifest.versionCode
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// PBKDF2-HMAC-SHA1 over the package name followed by the raw PNG bytes.
|
|
344
|
+
//
|
|
345
|
+
// Cobalt runs the derivation by hand because the JCA factory rejects a binary
|
|
346
|
+
// password; the loop it runs is standard PBKDF2, so this calls the platform's.
|
|
347
|
+
// The two are checked against each other in the token test.
|
|
348
|
+
function deriveSecretKey(packageName, aboutLogo) {
|
|
349
|
+
const password = Buffer.concat([Buffer.from(packageName, 'utf8'), aboutLogo]);
|
|
350
|
+
return crypto.pbkdf2Sync(password, ANDROID_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_SIZE, 'sha1');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The registration token for a national number, as the Android client signs it.
|
|
355
|
+
*
|
|
356
|
+
* material: as returned by extractMaterial (or loaded from its JSON form).
|
|
357
|
+
* national: the number without the country code, digits only.
|
|
358
|
+
*/
|
|
359
|
+
function computeToken(material, national) {
|
|
360
|
+
const mac = crypto.createHmac('sha1', material.secretKey);
|
|
361
|
+
for (const cert of material.certificates) mac.update(cert);
|
|
362
|
+
mac.update(material.classesDexMd5);
|
|
363
|
+
mac.update(Buffer.from(String(national), 'utf8'));
|
|
364
|
+
// Java's URLEncoder over base64: '+', '/' and '=' are the only characters it
|
|
365
|
+
// touches, and encodeURIComponent escapes those three identically.
|
|
366
|
+
return encodeURIComponent(mac.digest().toString('base64'));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Storage ──────────────────────────────────────────────────────────────────
|
|
370
|
+
//
|
|
371
|
+
// Only the derived pieces are kept, the way Cobalt caches them — the APK itself
|
|
372
|
+
// is never needed again.
|
|
373
|
+
|
|
374
|
+
function materialToJson(material) {
|
|
375
|
+
return {
|
|
376
|
+
packageName: material.packageName,
|
|
377
|
+
secretKey: material.secretKey.toString('base64'),
|
|
378
|
+
classesDexMd5: material.classesDexMd5.toString('base64'),
|
|
379
|
+
certificates: material.certificates.map(c => c.toString('base64')),
|
|
380
|
+
apkVersion: material.apkVersion || null,
|
|
381
|
+
apkVersionCode: material.apkVersionCode || null
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function materialFromJson(json) {
|
|
386
|
+
if (!json || !json.secretKey || !json.classesDexMd5 || !Array.isArray(json.certificates)) {
|
|
387
|
+
throw new Error('android APK material file is incomplete');
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
packageName: json.packageName || PERSONAL_PACKAGE,
|
|
391
|
+
secretKey: Buffer.from(json.secretKey, 'base64'),
|
|
392
|
+
classesDexMd5: Buffer.from(json.classesDexMd5, 'base64'),
|
|
393
|
+
certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
|
|
394
|
+
apkVersion: json.apkVersion || null,
|
|
395
|
+
apkVersionCode: json.apkVersionCode || null
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
module.exports = {
|
|
400
|
+
extractMaterial,
|
|
401
|
+
parseManifest,
|
|
402
|
+
deriveSecretKey,
|
|
403
|
+
computeToken,
|
|
404
|
+
materialToJson,
|
|
405
|
+
materialFromJson,
|
|
406
|
+
certificatesFromPkcs7,
|
|
407
|
+
readZipDirectory,
|
|
408
|
+
readZipEntry,
|
|
409
|
+
ANDROID_SALT,
|
|
410
|
+
ABOUT_LOGO_PATHS,
|
|
411
|
+
PERSONAL_PACKAGE,
|
|
412
|
+
BUSINESS_PACKAGE
|
|
413
|
+
};
|
package/lib/Client.js
CHANGED
|
@@ -59,6 +59,10 @@ const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
|
|
|
59
59
|
// with a different query.
|
|
60
60
|
const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
|
|
61
61
|
|
|
62
|
+
// How long an about is allowed to be. The apps stop you at this length; the
|
|
63
|
+
// server takes a longer one, answers <iq type="result"/> and keeps the old text.
|
|
64
|
+
const ABOUT_MAX_LENGTH = 139;
|
|
65
|
+
|
|
62
66
|
// The names privacy settings actually go by on the wire, and the friendly
|
|
63
67
|
// aliases this library has always accepted for them. The wire names are the
|
|
64
68
|
// right-hand column.
|
|
@@ -4446,17 +4450,62 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4446
4450
|
}
|
|
4447
4451
|
|
|
4448
4452
|
// Change bio/about text
|
|
4453
|
+
//
|
|
4454
|
+
// The <status> child is byte-for-byte what Baileys and whatsmeow send. The
|
|
4455
|
+
// envelope was not: both of them address this IQ to the server as a *JID*
|
|
4456
|
+
// with nobody in the user part — Baileys writes '@s.whatsapp.net', whatsmeow
|
|
4457
|
+
// uses types.ServerJID — which goes out as JID_PAIR + empty user + the server
|
|
4458
|
+
// token. This wrote the bare string 's.whatsapp.net', and a bare string is
|
|
4459
|
+
// encoded as a plain dictionary token, so where every other client puts an
|
|
4460
|
+
// address the server was reading text. That is the whole of the difference,
|
|
4461
|
+
// and it is invisible in a stanza log, which prints the node before it is
|
|
4462
|
+
// encoded: both spellings read `to="s.whatsapp.net"` on screen.
|
|
4463
|
+
//
|
|
4464
|
+
// The reply was thrown away as well. _sendIq answers null on a timeout and
|
|
4465
|
+
// hands back error stanzas unread, so a refusal, a silence and a success all
|
|
4466
|
+
// returned the same thing — which is how "about updated" came to be printed
|
|
4467
|
+
// for a change that never happened.
|
|
4449
4468
|
async changeAbout(text) {
|
|
4450
|
-
const
|
|
4469
|
+
const body = String(text == null ? '' : text);
|
|
4470
|
+
// The limit the apps impose. A longer one is dropped rather than refused,
|
|
4471
|
+
// so it would otherwise look exactly like this bug.
|
|
4472
|
+
if (Array.from(body).length > ABOUT_MAX_LENGTH) {
|
|
4473
|
+
throw new Error('changeAbout: an about is limited to ' + ABOUT_MAX_LENGTH +
|
|
4474
|
+
' characters — the server drops a longer one instead of refusing it');
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4451
4477
|
const node = new BinaryNode('iq', {
|
|
4452
|
-
id,
|
|
4478
|
+
id: this._genMsgId(),
|
|
4453
4479
|
xmlns: 'status',
|
|
4454
|
-
to: 's.whatsapp.net',
|
|
4480
|
+
to: jidStrToObj('@s.whatsapp.net'),
|
|
4455
4481
|
type: 'set'
|
|
4456
4482
|
}, [
|
|
4457
|
-
new BinaryNode('status', {}, Buffer.from(
|
|
4483
|
+
new BinaryNode('status', {}, Buffer.from(body, 'utf8'))
|
|
4458
4484
|
]);
|
|
4459
|
-
|
|
4485
|
+
|
|
4486
|
+
const resp = await this._sendIq(node);
|
|
4487
|
+
if (!resp) throw new Error('changeAbout: no reply from server (IQ timed out)');
|
|
4488
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
4489
|
+
const errNode = findChild(resp, 'error');
|
|
4490
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
4491
|
+
const errText = errNode && errNode.attrs && errNode.attrs.text;
|
|
4492
|
+
throw new Error('changeAbout: server rejected the change' +
|
|
4493
|
+
(code ? ' (' + code + (errText ? ' ' + errText : '') + ')' : ''));
|
|
4494
|
+
}
|
|
4495
|
+
return resp;
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
// The about this account currently has, as the server reports it.
|
|
4499
|
+
//
|
|
4500
|
+
// The set IQ is answered before the new text has to be visible anywhere, so
|
|
4501
|
+
// an empty result proves only that the stanza was understood. Reading it back
|
|
4502
|
+
// is the only way to tell an about the server stored from one it took and
|
|
4503
|
+
// dropped — and it is asked exactly the way a contact's is.
|
|
4504
|
+
async queryOwnAbout() {
|
|
4505
|
+
const me = this._store &&
|
|
4506
|
+
(this._store.phoneNumber || (this._store.me && this._store.me.id));
|
|
4507
|
+
if (!me) throw new Error('queryOwnAbout: not connected');
|
|
4508
|
+
return this.queryAbout(String(me));
|
|
4460
4509
|
}
|
|
4461
4510
|
|
|
4462
4511
|
// Change profile picture
|
package/lib/Registration.js
CHANGED
|
@@ -3,11 +3,50 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const tls = require('tls');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
6
9
|
const curveJs = require('curve25519-js');
|
|
10
|
+
const { v4: uuidv4 } = require('uuid');
|
|
7
11
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
12
|
+
const AndroidApk = require('./AndroidApk');
|
|
8
13
|
const attestation = require('./Attestation');
|
|
9
14
|
const { dbg: _whaDbg } = require('./logger');
|
|
10
15
|
|
|
16
|
+
// ---------- Request envelope ----------
|
|
17
|
+
//
|
|
18
|
+
// The registration server reads which platform is calling out of the
|
|
19
|
+
// User-Agent. There is no form field for it — neither this library nor Cobalt
|
|
20
|
+
// sends one — so a request whose User-Agent does not name a platform it knows is
|
|
21
|
+
// refused with {"param":"platform","reason":"bad_param"} on every endpoint. The
|
|
22
|
+
// giveaway is that /client_log is refused the same way, and its body carries no
|
|
23
|
+
// device fields at all: nothing about the platform is in the body to begin with.
|
|
24
|
+
//
|
|
25
|
+
// Android used to be sent as `WhatsApp/<version> A`, which names nothing, and
|
|
26
|
+
// was refused everywhere. iOS has always sent the full form and has always
|
|
27
|
+
// worked, which is why only one of the two platforms was broken.
|
|
28
|
+
//
|
|
29
|
+
// The three extra Android headers come from Cobalt's AndroidClientRegistration,
|
|
30
|
+
// which records them as observed on a live native Android registration. Its iOS
|
|
31
|
+
// counterpart documents that the native iOS client omits all three, so the iOS
|
|
32
|
+
// envelope stays exactly as it was.
|
|
33
|
+
function registrationHeaders(device, waVersion) {
|
|
34
|
+
if (device.os === 'android') {
|
|
35
|
+
return {
|
|
36
|
+
'User-Agent': `WhatsApp/${waVersion} Android/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
|
|
37
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
38
|
+
'Accept': 'text/json',
|
|
39
|
+
'WaMsysRequest': '1',
|
|
40
|
+
// A fresh one per request, the way the native client mints it.
|
|
41
|
+
'request_token': uuidv4()
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
'User-Agent': `WhatsApp/${waVersion} iOS/${device.osVersion} Device/${device.model}`,
|
|
46
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
// ---------- SOCKS4 / SOCKS5 / Tor support ----------
|
|
12
51
|
//
|
|
13
52
|
// Set SOCKS_PROXY (or TOR_PROXY) to route registration traffic through a proxy:
|
|
@@ -125,15 +164,11 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
|
125
164
|
tlsSocket.once('error', rej);
|
|
126
165
|
});
|
|
127
166
|
|
|
128
|
-
const
|
|
129
|
-
const userAgent = _dev.os === 'android'
|
|
130
|
-
? `WhatsApp/${waVersion} A`
|
|
131
|
-
: `WhatsApp/${waVersion} iOS/${_dev.osVersion} Device/${_dev.model}`;
|
|
167
|
+
const headers = registrationHeaders(getDeviceConfig(), waVersion);
|
|
132
168
|
const reqLines = [
|
|
133
169
|
`POST /v2${path} HTTP/1.1`,
|
|
134
170
|
`Host: ${dHost}`,
|
|
135
|
-
|
|
136
|
-
`Content-Type: application/x-www-form-urlencoded`,
|
|
171
|
+
...Object.keys(headers).map(k => `${k}: ${headers[k]}`),
|
|
137
172
|
`Content-Length: ${Buffer.byteLength(body)}`
|
|
138
173
|
];
|
|
139
174
|
if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
|
|
@@ -280,7 +315,6 @@ const {
|
|
|
280
315
|
REGISTRATION_ENDPOINT,
|
|
281
316
|
REGISTRATION_PUBLIC_KEY,
|
|
282
317
|
IOS_STATIC_TOKEN,
|
|
283
|
-
ANDROID_STATIC_TOKEN,
|
|
284
318
|
IOS_VERSION_FALLBACK,
|
|
285
319
|
ANDROID_VERSION_FALLBACK,
|
|
286
320
|
IOS_USER_AGENT,
|
|
@@ -492,20 +526,78 @@ async function fetchAndroidVersion() {
|
|
|
492
526
|
|
|
493
527
|
// Return the appropriate WhatsApp version for the active device.
|
|
494
528
|
// If WA_VERSION is set in the environment, that value is always used.
|
|
529
|
+
//
|
|
530
|
+
// On Android the APK the token material came from decides it, the way Cobalt
|
|
531
|
+
// announces the versionName of the APK it downloaded. The token is signed over
|
|
532
|
+
// that build's classes.dex, so a version read from anywhere else describes a
|
|
533
|
+
// different build than the one the token proves — which the server sees as a
|
|
534
|
+
// token that does not belong to the client sending it. The live lookup stays as
|
|
535
|
+
// the fallback for when there is no material yet.
|
|
495
536
|
async function fetchWaVersion(device) {
|
|
496
537
|
if (process.env.WA_VERSION) return process.env.WA_VERSION;
|
|
497
|
-
|
|
538
|
+
if (device && device.os === 'android') {
|
|
539
|
+
const material = tryLoadAndroidMaterial();
|
|
540
|
+
if (material && material.apkVersion) return material.apkVersion;
|
|
541
|
+
return fetchAndroidVersion();
|
|
542
|
+
}
|
|
543
|
+
return fetchIosVersion();
|
|
498
544
|
}
|
|
499
545
|
|
|
500
546
|
// ---------- Token computation ----------
|
|
501
|
-
//
|
|
502
|
-
// The
|
|
503
|
-
//
|
|
547
|
+
//
|
|
548
|
+
// The two platforms compute this differently, and only iOS derives it from a
|
|
549
|
+
// static string:
|
|
550
|
+
//
|
|
551
|
+
// iOS MD5( staticToken + MD5hex(waVersion) + nationalNumber )
|
|
552
|
+
// Android HMAC-SHA1, keyed by material taken out of the APK, over the
|
|
553
|
+
// signing certificates, MD5(classes.dex) and the national number
|
|
554
|
+
//
|
|
555
|
+
// Android was being sent the iOS calculation with a different constant put in
|
|
556
|
+
// front of it. No such constant exists — the Android client signs the token
|
|
557
|
+
// with its own APK — and the server answered every attempt with bad_token. See
|
|
558
|
+
// AndroidApk.js for the algorithm and where the material comes from.
|
|
559
|
+
//
|
|
560
|
+
// WA_STATIC_TOKEN therefore only means anything on iOS. It cannot substitute
|
|
561
|
+
// for the Android material, so it is deliberately not consulted there.
|
|
562
|
+
|
|
563
|
+
// Where the extracted Android material is kept. Beside the sessions by default,
|
|
564
|
+
// since that is the directory the CLI already owns.
|
|
565
|
+
function androidMaterialPath() {
|
|
566
|
+
return process.env.WA_ANDROID_APK_MATERIAL ||
|
|
567
|
+
path.join(os.homedir(), '.waSession', 'android-apk-material.json');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let _androidMaterial = null;
|
|
571
|
+
|
|
572
|
+
// The material if there is any, without insisting on it. fetchWaVersion asks
|
|
573
|
+
// this way: a missing file is the token's problem to report, not the version's.
|
|
574
|
+
function tryLoadAndroidMaterial() {
|
|
575
|
+
try { return loadAndroidMaterial(); } catch (_) { return null; }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function loadAndroidMaterial() {
|
|
579
|
+
if (_androidMaterial) return _androidMaterial;
|
|
580
|
+
const file = androidMaterialPath();
|
|
581
|
+
let raw;
|
|
582
|
+
try {
|
|
583
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
584
|
+
} catch (_) {
|
|
585
|
+
throw new Error('registering as Android needs the token material from a WhatsApp APK, ' +
|
|
586
|
+
'and there is none at ' + file + '. Extract it once per WhatsApp release with:\n' +
|
|
587
|
+
' wa apk-material <base.apk> [split.apk ...]\n' +
|
|
588
|
+
'Pull the APK off a phone or emulator that has WhatsApp installed (adb), or point ' +
|
|
589
|
+
'WA_ANDROID_APK_MATERIAL at a file you already have. Registering as iOS needs none of this.');
|
|
590
|
+
}
|
|
591
|
+
_androidMaterial = AndroidApk.materialFromJson(JSON.parse(raw));
|
|
592
|
+
return _androidMaterial;
|
|
593
|
+
}
|
|
504
594
|
|
|
505
595
|
function computeToken(waVersion, national) {
|
|
506
|
-
const device
|
|
507
|
-
|
|
508
|
-
|
|
596
|
+
const device = getDeviceConfig();
|
|
597
|
+
if (device.os === 'android') {
|
|
598
|
+
return AndroidApk.computeToken(loadAndroidMaterial(), national);
|
|
599
|
+
}
|
|
600
|
+
const staticToken = process.env.WA_STATIC_TOKEN || IOS_STATIC_TOKEN;
|
|
509
601
|
const versionHashHex = md5Bytes(waVersion).toString('hex');
|
|
510
602
|
return md5Hex(staticToken + versionHashHex + national);
|
|
511
603
|
}
|
|
@@ -703,16 +795,11 @@ function httpPost(path, body, waVersion, authHeader) {
|
|
|
703
795
|
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader);
|
|
704
796
|
|
|
705
797
|
return new Promise((resolve, reject) => {
|
|
706
|
-
const _httpDev = getDeviceConfig();
|
|
707
|
-
const userAgent = _httpDev.os === 'android'
|
|
708
|
-
? `WhatsApp/${waVersion} A`
|
|
709
|
-
: `WhatsApp/${waVersion} iOS/${_httpDev.osVersion} Device/${_httpDev.model}`;
|
|
710
798
|
const bodyBuf = Buffer.from(body, 'utf8');
|
|
711
|
-
const headers =
|
|
712
|
-
|
|
713
|
-
'Content-
|
|
714
|
-
|
|
715
|
-
};
|
|
799
|
+
const headers = Object.assign(
|
|
800
|
+
registrationHeaders(getDeviceConfig(), waVersion),
|
|
801
|
+
{ 'Content-Length': bodyBuf.length }
|
|
802
|
+
);
|
|
716
803
|
// Attestation cert-chain / App Attest header (Android cert chain, iOS
|
|
717
804
|
// <attestation>|<keyId>). Only present when body attestation is available.
|
|
718
805
|
if (authHeader) headers['Authorization'] = authHeader;
|
|
@@ -1373,6 +1460,9 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
|
|
|
1373
1460
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1374
1461
|
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
1375
1462
|
|
|
1463
|
+
// Token computation, exposed for tests. Not part of the public API.
|
|
1464
|
+
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders };
|
|
1465
|
+
|
|
1376
1466
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1377
1467
|
module.exports._verify = {
|
|
1378
1468
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
package/lib/constants.js
CHANGED
|
@@ -31,11 +31,15 @@ const WEB_VERSION_FALLBACK = [2, 3000, 1035194821];
|
|
|
31
31
|
// the name the account owner sees in the linked-devices list on their phone.
|
|
32
32
|
const WEB_BROWSER = ['Ubuntu', 'Chrome', '120.0.0.0'];
|
|
33
33
|
|
|
34
|
-
// Static tokens used in
|
|
34
|
+
// Static tokens used in the iOS registration token computation.
|
|
35
35
|
// token = MD5( staticToken + MD5hex(waVersion) + nationalNumber )
|
|
36
|
+
//
|
|
37
|
+
// iOS only. Android has no static token at all — it signs the token with
|
|
38
|
+
// material out of its own APK (see AndroidApk.js), and the value that used to
|
|
39
|
+
// sit here as ANDROID_STATIC_TOKEN was this same iOS calculation with an
|
|
40
|
+
// invented constant in front of it, which the server refused as bad_token.
|
|
36
41
|
const IOS_STATIC_TOKEN = '0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM';
|
|
37
42
|
const IOS_BUSINESS_STATIC_TOKEN = 'USUDuDYDeQhY4RF2fCSp5m3F6kJ1M2J8wS7bbNA2';
|
|
38
|
-
const ANDROID_STATIC_TOKEN = 'Y29Cs6AVNR2bj5PBeKSYFd1nAKuvNQ3h';
|
|
39
43
|
|
|
40
44
|
// WhatsApp version fallbacks — used when live fetch fails.
|
|
41
45
|
const IOS_VERSION_FALLBACK = '2.26.9.75';
|
|
@@ -228,7 +232,6 @@ module.exports = {
|
|
|
228
232
|
WEB_BROWSER,
|
|
229
233
|
IOS_STATIC_TOKEN,
|
|
230
234
|
IOS_BUSINESS_STATIC_TOKEN,
|
|
231
|
-
ANDROID_STATIC_TOKEN,
|
|
232
235
|
IOS_VERSION_FALLBACK,
|
|
233
236
|
ANDROID_VERSION_FALLBACK,
|
|
234
237
|
IOS_USER_AGENT,
|