whalibmob 5.12.10 → 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 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. Leave unset to use the built-in token
59
- # for the selected platform — only change this if you know what you are doing.
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
@@ -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
@@ -1544,6 +1549,80 @@ Both are optional and both keep working when omitted — you get an error naming
1544
1549
  > [!NOTE]
1545
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.
1546
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
+
1547
1626
  ### Device Attestation with Frida (optional)
1548
1627
 
1549
1628
  WhatsApp's registration servers score every `/code` and `/register` request on how
package/cli.js CHANGED
@@ -2328,10 +2328,12 @@ usage:
2328
2328
  wa registration --request-code <phone> request SMS code
2329
2329
  wa registration --register <phone> --code <code>
2330
2330
  wa registration --check <phone>
2331
+ wa apk-material <base.apk> [split.apk ...] read the Android token material
2331
2332
  wa version
2332
2333
 
2333
2334
  options:
2334
2335
  --session <dir> session directory (default: ~/.waSession)
2336
+ --out <file> where apk-material writes (default: <session dir>/android-apk-material.json)
2335
2337
  --sms connect by registering this number over SMS
2336
2338
  --pair connect by linking to an existing account (8-digit code)
2337
2339
  --method sms | voice | wa_old | email (default: sms)
@@ -2434,6 +2436,57 @@ async function main() {
2434
2436
  return;
2435
2437
  }
2436
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
+
2437
2490
  if (cmd === 'registration' || cmd === 'reg') {
2438
2491
  const rawPhone =
2439
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
+ };
@@ -3,9 +3,13 @@
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');
7
10
  const { v4: uuidv4 } = require('uuid');
8
11
  const { getDeviceConfig } = require('./DeviceConfig');
12
+ const AndroidApk = require('./AndroidApk');
9
13
  const attestation = require('./Attestation');
10
14
  const { dbg: _whaDbg } = require('./logger');
11
15
 
@@ -311,7 +315,6 @@ const {
311
315
  REGISTRATION_ENDPOINT,
312
316
  REGISTRATION_PUBLIC_KEY,
313
317
  IOS_STATIC_TOKEN,
314
- ANDROID_STATIC_TOKEN,
315
318
  IOS_VERSION_FALLBACK,
316
319
  ANDROID_VERSION_FALLBACK,
317
320
  IOS_USER_AGENT,
@@ -523,20 +526,78 @@ async function fetchAndroidVersion() {
523
526
 
524
527
  // Return the appropriate WhatsApp version for the active device.
525
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.
526
536
  async function fetchWaVersion(device) {
527
537
  if (process.env.WA_VERSION) return process.env.WA_VERSION;
528
- return (device && device.os === 'android') ? fetchAndroidVersion() : fetchIosVersion();
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();
529
544
  }
530
545
 
531
546
  // ---------- Token computation ----------
532
- // token = MD5( staticToken + MD5hex(waVersion) + nationalNumber )
533
- // The static token depends on the platform (iOS vs Android).
534
- // Override with WA_STATIC_TOKEN in .env if the bundled token becomes stale.
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
+ }
535
594
 
536
595
  function computeToken(waVersion, national) {
537
- const device = getDeviceConfig();
538
- const staticToken = process.env.WA_STATIC_TOKEN
539
- || (device.os === 'android' ? ANDROID_STATIC_TOKEN : IOS_STATIC_TOKEN);
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;
540
601
  const versionHashHex = md5Bytes(waVersion).toString('hex');
541
602
  return md5Hex(staticToken + versionHashHex + national);
542
603
  }
@@ -1399,6 +1460,9 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
1399
1460
  // The HTTP reader, exposed for tests. Not part of the public API.
1400
1461
  module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
1401
1462
 
1463
+ // Token computation, exposed for tests. Not part of the public API.
1464
+ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders };
1465
+
1402
1466
  // Challenge / two-factor internals, exposed for tests. Not part of the public API.
1403
1467
  module.exports._verify = {
1404
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 WhatsApp registration token computation.
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.12.10",
3
+ "version": "5.12.11",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",