whalibmob 5.12.10 → 5.12.13

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,41 @@
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. The
70
+ # first registration fetches the APK from Google Play and does this on its own —
71
+ # nothing to run first. To do it by hand, from a Play download or an APK you
72
+ # already have:
73
+ #
74
+ # wa apk-material --download
75
+ # wa apk-material base.apk [split_config.xxhdpi.apk ...]
76
+ #
77
+ # It is written to <session dir>/android-apk-material.json by default. Set this
78
+ # to keep it somewhere else. Registering as iOS needs none of it.
79
+ # WA_ANDROID_APK_MATERIAL=/path/to/android-apk-material.json
80
+
81
+ # Never fetch the APK automatically. Registration then fails with instructions
82
+ # instead of pulling a hundred megabytes unasked.
83
+ # WA_NO_APK_DOWNLOAD=1
84
+
85
+ # Register with material from an APK that is not signed by WhatsApp. Refused by
86
+ # default, because a re-signed APK yields a token that belongs to nobody and
87
+ # spends code requests against your number for nothing.
88
+ # WA_ALLOW_FOREIGN_APK=1
89
+
90
+
63
91
  # ─── Proxy ───────────────────────────────────────────────────────────────────
64
92
  # Route registration traffic through a SOCKS proxy. Requires the `socks`
65
93
  # package: npm install socks
package/README.md CHANGED
@@ -120,6 +120,10 @@ 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)
124
+ - [Why an APK is involved at all](#why-an-apk-is-involved-at-all)
125
+ - [Fetching the APK on its own](#fetching-the-apk-on-its-own)
126
+ - [Reading it out of an APK you already have](#reading-it-out-of-an-apk-you-already-have)
123
127
  - [Device Attestation with Frida (optional)](#device-attestation-with-frida-optional)
124
128
  - [Connect](#connect)
125
129
  - [Linking to an Existing Account (Pairing Code)](#linking-to-an-existing-account-pairing-code)
@@ -286,6 +290,16 @@ wa registration --request-code 919634847671 --method voice
286
290
  wa registration --request-code 919634847671 --method wa_old
287
291
  ```
288
292
 
293
+ **Registering as Android** is the same command with the platform named. Nothing
294
+ else to prepare — see [what it does behind that one command](#registering-as-android):
295
+
296
+ ```sh
297
+ WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug
298
+ ```
299
+
300
+ Set `WA_OS=android` in a `.env` file instead and the plain command is enough.
301
+ `--debug` prints every request and reply, which is worth having the first time.
302
+
289
303
  The CLI sends the code request, prints the result, and then **stays open** in the interactive shell. You will see:
290
304
 
291
305
  ```
@@ -1544,6 +1558,148 @@ Both are optional and both keep working when omitted — you get an error naming
1544
1558
  > [!NOTE]
1545
1559
  > 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
1560
 
1561
+ ### Registering as Android
1562
+
1563
+ **There is nothing to do first.** Name the platform and register:
1564
+
1565
+ ```sh
1566
+ WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug
1567
+ ```
1568
+
1569
+ The first Android registration finds no token material, fetches the WhatsApp APK
1570
+ from Google Play, reads what it needs out of it, checks that WhatsApp really
1571
+ signed it, and carries on to the code request — one command, no APK to find:
1572
+
1573
+ ```
1574
+ no Android token material yet — fetching the WhatsApp APK from Google Play
1575
+ version 2.26.30.3 (code 263000302)
1576
+ downloading base.apk and 1 density split (of 24 the server offered): config.xxxhdpi
1577
+ token material written to /home/you/.waSession/android-apk-material.json
1578
+ requesting sms code for +919634847671...
1579
+ status sent
1580
+ ```
1581
+
1582
+ It happens once. Every later registration reads the file. `WA_NO_APK_DOWNLOAD=1`
1583
+ turns the fetch off if you would rather it never pulled a hundred megabytes
1584
+ unasked, and `wa apk-material` below does the same job by hand.
1585
+
1586
+ The rest of this section is what happens behind that one command, and how to
1587
+ drive each part yourself.
1588
+
1589
+ #### Why an APK is involved at all
1590
+
1591
+ The registration token is computed differently on each platform, and only iOS
1592
+ derives it from a constant. The Android client signs it with material out of its
1593
+ own APK:
1594
+
1595
+ ```
1596
+ key = PBKDF2-HMAC-SHA1(password = "com.whatsapp" + about_logo.png,
1597
+ salt = fixed, iterations = 128, length = 64)
1598
+ token = urlencode(base64(HMAC-SHA1(key, signing certificates
1599
+ + MD5(classes.dex)
1600
+ + national number)))
1601
+ ```
1602
+
1603
+ None of that can be derived, so it has to be read out of a real APK once. Sending
1604
+ the iOS-shaped token as Android is answered with `{"reason":"bad_token"}`, and no
1605
+ value in `WA_STATIC_TOKEN` changes that — the constant is not what is wrong, the
1606
+ formula is. `WA_STATIC_TOKEN` applies to iOS only.
1607
+
1608
+ #### Fetching the APK on its own
1609
+
1610
+ This runs by itself on a registration that finds no material. To do it separately
1611
+ — to refresh after a WhatsApp release, or just to see what it picks up:
1612
+
1613
+ ```sh
1614
+ wa apk-material --download
1615
+ ```
1616
+
1617
+ It is the route Cobalt takes: an anonymous Play Store token from the Aurora OSS
1618
+ dispenser, then Google's own `/fdfe` catalogue and delivery endpoints, then the
1619
+ signed CDN URLs. **No Google account of yours is involved.** Only the density
1620
+ splits are downloaded — the token's key comes from `about_logo.png`, which lives
1621
+ in one of those, and the architecture and language splits would be a hundred
1622
+ megabytes nothing here reads.
1623
+
1624
+ Two caveats. The dispenser is a free third-party service: when it is down or rate
1625
+ limiting, this fails and an APK from a phone still works. And downloading from
1626
+ Play this way is against Play's terms of service, which is your call to make.
1627
+
1628
+ Play serves the splits that suit the device profile it is asked as, not the full
1629
+ set a bundle holds, so the density this yields can differ from the one in a
1630
+ complete bundle — and a different density is a different key. The `about_logo`
1631
+ line in the output names which one was used.
1632
+
1633
+ #### Reading it out of an APK you already have
1634
+
1635
+ Pull one off any phone or emulator that has WhatsApp installed:
1636
+
1637
+ ```sh
1638
+ adb shell pm path com.whatsapp
1639
+ # package:/data/app/~~xyz==/com.whatsapp-abc==/base.apk
1640
+ # package:/data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk
1641
+ adb pull /data/app/~~xyz==/com.whatsapp-abc==/base.apk
1642
+ adb pull /data/app/~~xyz==/com.whatsapp-abc==/split_config.xxhdpi.apk
1643
+ ```
1644
+
1645
+ Recent releases ship as an App Bundle, so `about_logo.png` often lives in a
1646
+ density split rather than in `base.apk`. Pull the `split_config.*dpi.apk` files
1647
+ too and pass them along — only splits whose name ends in `dpi` are searched.
1648
+
1649
+ **Read the material out of it:**
1650
+
1651
+ ```sh
1652
+ wa apk-material base.apk split_config.xxhdpi.apk
1653
+ ```
1654
+
1655
+ ```
1656
+ reading base.apk...
1657
+ package com.whatsapp
1658
+ version 2.26.25.80 (code 260908001)
1659
+ certificates 1
1660
+ signed by C=US, ST=California, L=Santa Clara, O=WhatsApp Inc., …
1661
+ sha256 AD:AD:64:31:29:8F:64:2B:…
1662
+ classes.dex md5 5c71f02aaad331e16e436bfa83ea3c5b
1663
+ written to /home/you/.waSession/android-apk-material.json
1664
+ ```
1665
+
1666
+ **Watch the `signed by` line.** The token is an HMAC over the signing
1667
+ certificates, so it is only the token the server expects when those certificates
1668
+ are WhatsApp's own. Mirrors re-sign the APKs they host, commonly with the AOSP
1669
+ test key whose private half ships in the Android sources — a re-signed APK yields
1670
+ a token that is well-formed and belongs to nobody. Anything other than WhatsApp
1671
+ in that subject is called out here as a warning, and registration refuses to
1672
+ start on it rather than spending code requests against your number on something
1673
+ that cannot succeed. `WA_ALLOW_FOREIGN_APK=1` sends it anyway.
1674
+
1675
+ Only the derived pieces are kept — the APK is never needed again. Registration
1676
+ picks the file up on its own from then on. `--out <file>` writes it elsewhere,
1677
+ and `WA_ANDROID_APK_MATERIAL` points at it if you keep it somewhere else.
1678
+
1679
+ **The version comes from the APK too.** It is read out of the binary
1680
+ `AndroidManifest.xml` and announced from then on, instead of the version the Play
1681
+ Store currently lists. The token is signed over *this* build's `classes.dex`, so
1682
+ announcing any other version describes a build the token does not belong to.
1683
+ `WA_VERSION` still overrides everything if you need it to; on a manifest with no
1684
+ `versionName`, pass `--version <x.y.z.w>`.
1685
+
1686
+ **Refresh it on a new WhatsApp release.** `classes.dex` changes every release and
1687
+ its MD5 is signed into the token, so material from an older build stops matching
1688
+ the version being announced. `wa apk-material --download` re-reads the current
1689
+ one; deleting the file and registering again does the same thing on its own.
1690
+
1691
+ Programmatically:
1692
+
1693
+ ```js
1694
+ const { extractMaterial, computeToken, materialToJson } = require('whalibmob/lib/AndroidApk')
1695
+
1696
+ const material = extractMaterial(
1697
+ fs.readFileSync('base.apk'),
1698
+ [{ name: 'split_config.xxhdpi.apk', data: fs.readFileSync('split_config.xxhdpi.apk') }]
1699
+ )
1700
+ fs.writeFileSync('android-apk-material.json', JSON.stringify(materialToJson(material)))
1701
+ ```
1702
+
1547
1703
  ### Device Attestation with Frida (optional)
1548
1704
 
1549
1705
  WhatsApp's registration servers score every `/code` and `/register` request on how
package/cli.js CHANGED
@@ -252,9 +252,26 @@ function enableWireTrace() {
252
252
  };
253
253
  req.on('response', (res) => {
254
254
  trace(`\n${C.http}${stamp()} ◀── HTTP ${res.statusCode} from ${host}${C.off}`);
255
- let body = '';
256
- res.on('data', d => { if (body.length < 8192) body += d.toString('utf8'); });
257
- res.on('end', () => { if (body) trace(` ${C.dim}${body}${C.off}`); });
255
+ // Registration answers in JSON, so the body was printed as text. An APK
256
+ // download answers in tens of megabytes of binary, and printing that as
257
+ // text emptied a terminal full of control characters. Keep a bounded head
258
+ // of it, decide from those bytes whether it is text at all, and say what
259
+ // it was rather than showing it when it is not.
260
+ const head = [];
261
+ let seen = 0, total = 0;
262
+ res.on('data', (d) => {
263
+ total += d.length;
264
+ if (seen < 4096) { head.push(d); seen += d.length; }
265
+ });
266
+ res.on('end', () => {
267
+ if (!total) return;
268
+ const buf = Buffer.concat(head).slice(0, 4096);
269
+ if (isPrintable(buf)) {
270
+ trace(` ${C.dim}${buf.toString('utf8')}${total > buf.length ? ' …' : ''}${C.off}`);
271
+ } else {
272
+ trace(` ${C.dim}[${total} bytes, binary]${C.off}`);
273
+ }
274
+ });
258
275
  });
259
276
  return req;
260
277
  };
@@ -2068,7 +2085,7 @@ async function handleLine(line) {
2068
2085
  }
2069
2086
  const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
2070
2087
  out('requesting ' + methodLabel + ' code for +' + ph + '...');
2071
- const codeOpts = method === 'email' ? { email: emailAddr } : {};
2088
+ const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {}, { onProgress: out });
2072
2089
  const r = await requestSmsCode(store, method, codeOpts);
2073
2090
  store.codePending = true;
2074
2091
  saveStore(store, sessFile);
@@ -2083,7 +2100,7 @@ async function handleLine(line) {
2083
2100
  const file = path.join(_sessDir, `${ph}.json`);
2084
2101
  const store = loadStore(file) || initAuthCreds(ph);
2085
2102
  out('verifying...');
2086
- const r = await verifyCode(store, code, registrationPrompts());
2103
+ const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
2087
2104
  if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
2088
2105
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2089
2106
  const finalStore = r.store || store;
@@ -2328,10 +2345,13 @@ usage:
2328
2345
  wa registration --request-code <phone> request SMS code
2329
2346
  wa registration --register <phone> --code <code>
2330
2347
  wa registration --check <phone>
2348
+ wa apk-material <base.apk> [split.apk ...] read the Android token material
2349
+ wa apk-material --download fetch that APK from Google Play
2331
2350
  wa version
2332
2351
 
2333
2352
  options:
2334
2353
  --session <dir> session directory (default: ~/.waSession)
2354
+ --out <file> where apk-material writes (default: <session dir>/android-apk-material.json)
2335
2355
  --sms connect by registering this number over SMS
2336
2356
  --pair connect by linking to an existing account (8-digit code)
2337
2357
  --method sms | voice | wa_old | email (default: sms)
@@ -2434,6 +2454,86 @@ async function main() {
2434
2454
  return;
2435
2455
  }
2436
2456
 
2457
+ // Read the Android registration token material out of a WhatsApp APK.
2458
+ //
2459
+ // Registering as Android signs its token with the APK's own signing
2460
+ // certificates, the MD5 of its classes.dex, and a key derived from
2461
+ // about_logo.png — none of which can be derived, so they are read out of a
2462
+ // real APK once and kept. Registering as iOS needs none of it.
2463
+ if (cmd === 'apk-material') {
2464
+ // parseArgs puts the first non-flag argument in sub, the rest in pos.
2465
+ const apks = [sub, ...pos].filter(Boolean);
2466
+ if (!apks.length && !flags.download) {
2467
+ fail('usage: wa apk-material <base.apk> [split.apk ...]');
2468
+ out(' wa apk-material --download fetch the APK from Google Play instead');
2469
+ process.exit(1);
2470
+ }
2471
+ const outFile = flags.out ||
2472
+ process.env.WA_ANDROID_APK_MATERIAL ||
2473
+ path.join(_sessDir, 'android-apk-material.json');
2474
+ try {
2475
+ const AndroidApk = require('./lib/AndroidApk');
2476
+ let material;
2477
+ if (flags.download) {
2478
+ const PlayStore = require('./lib/PlayStore');
2479
+ out('fetching ' + (flags.business ? 'com.whatsapp.w4b' : 'com.whatsapp') + ' from Google Play...');
2480
+ const apk = await PlayStore.downloadApk({
2481
+ packageName: flags.business ? 'com.whatsapp.w4b' : 'com.whatsapp',
2482
+ onProgress: (m) => out(' ' + m)
2483
+ });
2484
+ material = AndroidApk.extractMaterial(apk.base, apk.splits);
2485
+ // The catalogue's version is the authority when the manifest has none.
2486
+ if (!material.apkVersion) material.apkVersion = apk.versionName;
2487
+ if (!material.apkVersionCode) material.apkVersionCode = apk.versionCode;
2488
+ } else {
2489
+ const [basePath, ...splitPaths] = apks;
2490
+ out('reading ' + basePath + '...');
2491
+ material = AndroidApk.extractMaterial(
2492
+ fs.readFileSync(basePath),
2493
+ splitPaths.map(p => ({ name: path.basename(p), data: fs.readFileSync(p) }))
2494
+ );
2495
+ }
2496
+ if (flags.version) material.apkVersion = String(flags.version);
2497
+ if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2498
+ fs.writeFileSync(outFile, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
2499
+ kv('package', material.packageName);
2500
+ kv('version', material.apkVersion
2501
+ ? material.apkVersion + (material.apkVersionCode ? ' (code ' + material.apkVersionCode + ')' : '')
2502
+ : '(not in the manifest — pass --version <x.y.z.w>)');
2503
+ if (material.aboutLogoFrom) kv('about_logo', material.aboutLogoFrom);
2504
+ kv('certificates', String(material.certificates.length));
2505
+ const signer = AndroidApk.describeCertificate(material.certificates[0]);
2506
+ if (signer) {
2507
+ kv('signed by', signer.subject);
2508
+ kv('sha256', signer.fingerprint256);
2509
+ }
2510
+ kv('classes.dex md5', material.classesDexMd5.toString('hex'));
2511
+ kv('written to', outFile);
2512
+ out('');
2513
+ if (signer && !AndroidApk.looksLikeWhatsAppCertificate(signer)) {
2514
+ warn('this APK is not signed by WhatsApp — the subject above is somebody else.');
2515
+ out(' Mirrors re-sign the APKs they host, and re-signing replaces the certificate');
2516
+ out(' the token is built from. The token will come out well-formed and belong to');
2517
+ out(' nobody, which the server answers with bad_token. Use the APK installed on a');
2518
+ out(' phone instead: pm path com.whatsapp');
2519
+ out('');
2520
+ }
2521
+ if (material.apkVersion) {
2522
+ out(' Registration will announce ' + material.apkVersion + ' from now on, because the');
2523
+ out(' token is signed over this APK — the live Play Store version would name a');
2524
+ out(' different build. WA_VERSION still overrides it.');
2525
+ } else {
2526
+ out(' The manifest carries no versionName, so the live Play Store version will be');
2527
+ out(' announced. If that does not match this APK, pass --version or set WA_VERSION.');
2528
+ }
2529
+ out(' Re-run this when you update the APK — classes.dex changes every release.');
2530
+ } catch (e) {
2531
+ fail(e.message);
2532
+ process.exit(1);
2533
+ }
2534
+ return;
2535
+ }
2536
+
2437
2537
  if (cmd === 'registration' || cmd === 'reg') {
2438
2538
  const rawPhone =
2439
2539
  flags['request-code'] !== undefined && flags['request-code'] !== true ? String(flags['request-code']) :
@@ -2488,7 +2588,7 @@ async function main() {
2488
2588
  const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
2489
2589
  out('requesting ' + methodLabel + ' code for +' + ph + '...');
2490
2590
  try {
2491
- const codeOpts = method === 'email' ? { email: emailAddr } : {};
2591
+ const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {}, { onProgress: out });
2492
2592
  const r = await requestSmsCode(store, method, codeOpts);
2493
2593
  store.codePending = true;
2494
2594
  saveStore(store, sessFile);
@@ -2514,7 +2614,7 @@ async function main() {
2514
2614
  const store = loadStore(file) || initAuthCreds(ph);
2515
2615
  out('verifying code for +' + ph + '...');
2516
2616
  try {
2517
- const r = await verifyCode(store, code, registrationPrompts());
2617
+ const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
2518
2618
  if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
2519
2619
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2520
2620
  const finalStore = r.store || store;