whalibmob 5.12.11 → 5.12.14

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
@@ -66,14 +66,27 @@
66
66
 
67
67
 
68
68
  # ─── Android registration token material ─────────────────────────────────────
69
- # Registering with WA_OS=android needs material read out of a WhatsApp APK once:
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:
70
73
  #
74
+ # wa apk-material --download
71
75
  # wa apk-material base.apk [split_config.xxhdpi.apk ...]
72
76
  #
73
77
  # It is written to <session dir>/android-apk-material.json by default. Set this
74
78
  # to keep it somewhere else. Registering as iOS needs none of it.
75
79
  # WA_ANDROID_APK_MATERIAL=/path/to/android-apk-material.json
76
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
+
77
90
 
78
91
  # ─── Proxy ───────────────────────────────────────────────────────────────────
79
92
  # Route registration traffic through a SOCKS proxy. Requires the `socks`
package/README.md CHANGED
@@ -121,6 +121,9 @@ npm install -g whalibmob
121
121
  - [Connecting Account](#connecting-account)
122
122
  - [Register a New Number](#register-a-new-number)
123
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)
124
127
  - [Device Attestation with Frida (optional)](#device-attestation-with-frida-optional)
125
128
  - [Connect](#connect)
126
129
  - [Linking to an Existing Account (Pairing Code)](#linking-to-an-existing-account-pairing-code)
@@ -274,10 +277,6 @@ wa version
274
277
 
275
278
  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.
276
279
 
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
-
281
280
  **Step 1 — request a verification code**
282
281
 
283
282
  ```sh
@@ -291,6 +290,16 @@ wa registration --request-code 919634847671 --method voice
291
290
  wa registration --request-code 919634847671 --method wa_old
292
291
  ```
293
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
+
294
303
  The CLI sends the code request, prints the result, and then **stays open** in the interactive shell. You will see:
295
304
 
296
305
  ```
@@ -1551,6 +1560,34 @@ Both are optional and both keep working when omitted — you get an error naming
1551
1560
 
1552
1561
  ### Registering as Android
1553
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
+
1554
1591
  The registration token is computed differently on each platform, and only iOS
1555
1592
  derives it from a constant. The Android client signs it with material out of its
1556
1593
  own APK:
@@ -1568,7 +1605,34 @@ the iOS-shaped token as Android is answered with `{"reason":"bad_token"}`, and n
1568
1605
  value in `WA_STATIC_TOKEN` changes that — the constant is not what is wrong, the
1569
1606
  formula is. `WA_STATIC_TOKEN` applies to iOS only.
1570
1607
 
1571
- **Get an APK.** Pull it off any phone or emulator that has WhatsApp installed:
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:
1572
1636
 
1573
1637
  ```sh
1574
1638
  adb shell pm path com.whatsapp
@@ -1593,10 +1657,21 @@ reading base.apk...
1593
1657
  package com.whatsapp
1594
1658
  version 2.26.25.80 (code 260908001)
1595
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:…
1596
1662
  classes.dex md5 5c71f02aaad331e16e436bfa83ea3c5b
1597
1663
  written to /home/you/.waSession/android-apk-material.json
1598
1664
  ```
1599
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
+
1600
1675
  Only the derived pieces are kept — the APK is never needed again. Registration
1601
1676
  picks the file up on its own from then on. `--out <file>` writes it elsewhere,
1602
1677
  and `WA_ANDROID_APK_MATERIAL` points at it if you keep it somewhere else.
@@ -1608,8 +1683,10 @@ announcing any other version describes a build the token does not belong to.
1608
1683
  `WA_VERSION` still overrides everything if you need it to; on a manifest with no
1609
1684
  `versionName`, pass `--version <x.y.z.w>`.
1610
1685
 
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.
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.
1613
1690
 
1614
1691
  Programmatically:
1615
1692
 
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;
@@ -2329,6 +2346,7 @@ usage:
2329
2346
  wa registration --register <phone> --code <code>
2330
2347
  wa registration --check <phone>
2331
2348
  wa apk-material <base.apk> [split.apk ...] read the Android token material
2349
+ wa apk-material --download fetch that APK from Google Play
2332
2350
  wa version
2333
2351
 
2334
2352
  options:
@@ -2445,8 +2463,9 @@ async function main() {
2445
2463
  if (cmd === 'apk-material') {
2446
2464
  // parseArgs puts the first non-flag argument in sub, the rest in pos.
2447
2465
  const apks = [sub, ...pos].filter(Boolean);
2448
- if (!apks.length) {
2466
+ if (!apks.length && !flags.download) {
2449
2467
  fail('usage: wa apk-material <base.apk> [split.apk ...]');
2468
+ out(' wa apk-material --download fetch the APK from Google Play instead');
2450
2469
  process.exit(1);
2451
2470
  }
2452
2471
  const outFile = flags.out ||
@@ -2454,12 +2473,26 @@ async function main() {
2454
2473
  path.join(_sessDir, 'android-apk-material.json');
2455
2474
  try {
2456
2475
  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
- );
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
+ }
2463
2496
  if (flags.version) material.apkVersion = String(flags.version);
2464
2497
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2465
2498
  fs.writeFileSync(outFile, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
@@ -2467,10 +2500,24 @@ async function main() {
2467
2500
  kv('version', material.apkVersion
2468
2501
  ? material.apkVersion + (material.apkVersionCode ? ' (code ' + material.apkVersionCode + ')' : '')
2469
2502
  : '(not in the manifest — pass --version <x.y.z.w>)');
2503
+ if (material.aboutLogoFrom) kv('about_logo', material.aboutLogoFrom);
2470
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
+ }
2471
2510
  kv('classes.dex md5', material.classesDexMd5.toString('hex'));
2472
2511
  kv('written to', outFile);
2473
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
+ }
2474
2521
  if (material.apkVersion) {
2475
2522
  out(' Registration will announce ' + material.apkVersion + ' from now on, because the');
2476
2523
  out(' token is signed over this APK — the live Play Store version would name a');
@@ -2541,7 +2588,7 @@ async function main() {
2541
2588
  const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
2542
2589
  out('requesting ' + methodLabel + ' code for +' + ph + '...');
2543
2590
  try {
2544
- const codeOpts = method === 'email' ? { email: emailAddr } : {};
2591
+ const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {}, { onProgress: out });
2545
2592
  const r = await requestSmsCode(store, method, codeOpts);
2546
2593
  store.codePending = true;
2547
2594
  saveStore(store, sessFile);
@@ -2567,7 +2614,7 @@ async function main() {
2567
2614
  const store = loadStore(file) || initAuthCreds(ph);
2568
2615
  out('verifying code for +' + ph + '...');
2569
2616
  try {
2570
- const r = await verifyCode(store, code, registrationPrompts());
2617
+ const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
2571
2618
  if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
2572
2619
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2573
2620
  const finalStore = r.store || store;
package/lib/AndroidApk.js CHANGED
@@ -289,21 +289,31 @@ function _isDensitySplit(fileName) {
289
289
  function extractMaterial(base, splits) {
290
290
  const entries = readZipDirectory(base);
291
291
 
292
+ // Which about_logo.png, when there is more than one.
293
+ //
294
+ // A released bundle ships the drawable in every density split — seven of them
295
+ // — and its bytes are the PBKDF2 password, so picking a different density
296
+ // yields a different key and a token the server will not accept. Searching
297
+ // split by split makes the answer depend on the order the splits happen to be
298
+ // passed in, which for a shell glob is alphabetical and for Cobalt is whatever
299
+ // its download map iterated: same APK, different token.
300
+ //
301
+ // So the path decides, not the split. ABOUT_LOGO_PATHS is ordered — hdpi
302
+ // first, the xxhdpi fallback last — and every candidate is searched for the
303
+ // first path before any is searched for the second. The base APK keeps
304
+ // priority within each path, since a release that still carries the drawable
305
+ // itself is not a bundle at all.
292
306
  let aboutLogo = null;
307
+ let aboutLogoFrom = null;
293
308
  for (const p of ABOUT_LOGO_PATHS) {
294
309
  aboutLogo = readZipEntry(base, entries, p);
295
- if (aboutLogo) break;
296
- }
297
- if (!aboutLogo) {
310
+ if (aboutLogo) { aboutLogoFrom = p; break; }
298
311
  for (const split of (splits || [])) {
299
312
  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;
313
+ aboutLogo = readZipEntry(split.data, readZipDirectory(split.data), p);
314
+ if (aboutLogo) { aboutLogoFrom = split.name + ':' + p; break; }
306
315
  }
316
+ if (aboutLogo) break;
307
317
  }
308
318
  if (!aboutLogo) {
309
319
  throw new Error('about_logo.png is in neither the base APK nor any density split — ' +
@@ -333,6 +343,9 @@ function extractMaterial(base, splits) {
333
343
  secretKey: deriveSecretKey(packageName, aboutLogo),
334
344
  classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
335
345
  certificates,
346
+ // Which drawable the key came from — worth showing, since a bundle carries
347
+ // one per density and only one of them makes the token the server expects.
348
+ aboutLogoFrom,
336
349
  // The version this APK is. Announced instead of the live one, because the
337
350
  // token is signed over this build's classes.dex.
338
351
  apkVersion: manifest.versionName,
@@ -366,6 +379,40 @@ function computeToken(material, national) {
366
379
  return encodeURIComponent(mac.digest().toString('base64'));
367
380
  }
368
381
 
382
+ // Who signed the APK, so it can be told apart from a repack.
383
+ //
384
+ // The token is an HMAC over the signing certificates, so it is only the token
385
+ // the server expects when those certificates are WhatsApp's own. An APK a mirror
386
+ // re-signed carries the mirror's certificate instead and produces a token that
387
+ // is perfectly well-formed and belongs to nobody — which comes back as
388
+ // bad_token, indistinguishable from having no material at all. Reading the
389
+ // subject out is what turns that into something visible before it is sent.
390
+ //
391
+ // Returns null on a runtime without X509Certificate (Node < 15.6) rather than
392
+ // making the extraction depend on it.
393
+ function describeCertificate(der) {
394
+ if (typeof crypto.X509Certificate !== 'function') return null;
395
+ try {
396
+ const cert = new crypto.X509Certificate(der);
397
+ return {
398
+ subject: cert.subject.replace(/\n/g, ', '),
399
+ issuer: cert.issuer.replace(/\n/g, ', '),
400
+ fingerprint256: cert.fingerprint256,
401
+ validFrom: cert.validFrom,
402
+ validTo: cert.validTo
403
+ };
404
+ } catch (_) {
405
+ return null;
406
+ }
407
+ }
408
+
409
+ // WhatsApp's own signing certificate names the company in its subject. A repack
410
+ // cannot keep that — re-signing replaces the certificate outright — so a subject
411
+ // without it is the one thing worth saying out loud.
412
+ function looksLikeWhatsAppCertificate(description) {
413
+ return !!(description && /WhatsApp/i.test(description.subject));
414
+ }
415
+
369
416
  // ─── Storage ──────────────────────────────────────────────────────────────────
370
417
  //
371
418
  // Only the derived pieces are kept, the way Cobalt caches them — the APK itself
@@ -399,6 +446,8 @@ function materialFromJson(json) {
399
446
  module.exports = {
400
447
  extractMaterial,
401
448
  parseManifest,
449
+ describeCertificate,
450
+ looksLikeWhatsAppCertificate,
402
451
  deriveSecretKey,
403
452
  computeToken,
404
453
  materialToJson,
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+
3
+ // Fetching the WhatsApp APK from Google Play, the way Cobalt's PlayStoreUtils
4
+ // does it, so the Android token material can be refreshed without anyone having
5
+ // to find an APK by hand.
6
+ //
7
+ // The route, in three hops:
8
+ //
9
+ // 1. POST an Aurora-style device profile to the Aurora OSS token dispenser,
10
+ // which mints an anonymous Google account and hands back a Play auth token
11
+ // plus the device metadata that has to accompany it. No Google account of
12
+ // the caller's is involved at any point.
13
+ // 2. GET /fdfe/details for the version, then /fdfe/purchase to acquire the
14
+ // free app, then /fdfe/delivery for the signed CDN URLs. All three answer
15
+ // in protobuf.
16
+ // 3. GET each URL with the delivery cookies attached, which yields the base
17
+ // APK and every split.
18
+ //
19
+ // Two things are worth knowing before relying on this. The dispenser is a free
20
+ // third-party service — when it is down or rate limited, so is this, and the
21
+ // APK on a phone is always available where this is not. And a current WhatsApp
22
+ // bundle is well over a hundred megabytes across its splits, which is a real
23
+ // download on a phone connection; only the density splits are actually needed
24
+ // for the token, so this fetches only those.
25
+
26
+ const https = require('https');
27
+ const { URL } = require('url');
28
+ const DEVICE_PROFILE = require('./playstore-device');
29
+ const { dbg: _whaDbg } = require('./logger');
30
+
31
+ const AURORA_DISPENSER_URL = 'https://auroraoss.com/api/auth';
32
+ // The dispenser refuses a request with an arbitrary user-agent.
33
+ const AURORA_USER_AGENT = 'com.aurora.store-4.6.1-70';
34
+
35
+ const FDFE_BASE_URL = 'https://android.clients.google.com/fdfe';
36
+ const DETAILS_URL = FDFE_BASE_URL + '/details';
37
+ const PURCHASE_URL = FDFE_BASE_URL + '/purchase';
38
+ const DELIVERY_URL = FDFE_BASE_URL + '/delivery';
39
+
40
+ // Which Play protocol features the client claims to understand. The same blob
41
+ // gplaydl and the Aurora Store ship; required on every FDFE call.
42
+ const X_DFE_ENCODED_TARGETS =
43
+ 'CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxXrBPsDlQUdMfOLAfoFrwEHgAcBrQYhoA0cGt4MKK0Y2gI';
44
+
45
+ const FALLBACK_FINSKY_USER_AGENT =
46
+ 'Android-Finsky/41.2.29-23 [0] [PR] 639844241 ' +
47
+ '(api=3,versionCode=84122900,sdk=34,device=lynx,' +
48
+ 'hardware=lynx,product=lynx,platformVersionRelease=14,' +
49
+ 'model=Pixel%207a,buildId=UQ1A.231205.015,' +
50
+ 'isWideScreen=0,supportedAbis=arm64-v8a;armeabi-v7a;armeabi)';
51
+
52
+ const LOCALE = 'en_US';
53
+ const REQUEST_TIMEOUT = 120000;
54
+
55
+ const PERSONAL_PACKAGE = 'com.whatsapp';
56
+
57
+ // ─── protobuf ─────────────────────────────────────────────────────────────────
58
+ //
59
+ // Only enough of the wire format to walk to a handful of known fields. Every
60
+ // message here is read by field number, and anything unrecognised is skipped —
61
+ // which is most of what Play returns.
62
+
63
+ function _readVarint(buf, pos) {
64
+ let result = 0, shift = 0, byte;
65
+ do {
66
+ if (pos >= buf.length) throw new Error('truncated varint');
67
+ byte = buf[pos++];
68
+ result += (byte & 0x7f) * Math.pow(2, shift);
69
+ shift += 7;
70
+ } while (byte & 0x80);
71
+ return { value: result, pos };
72
+ }
73
+
74
+ // field number → array of values. Length-delimited fields come back as Buffers,
75
+ // varints as numbers; everything else is skipped.
76
+ function decodeFields(buf) {
77
+ const out = new Map();
78
+ let pos = 0;
79
+ while (pos < buf.length) {
80
+ let key;
81
+ try { ({ value: key, pos } = _readVarint(buf, pos)); } catch (_) { break; }
82
+ const field = Math.floor(key / 8);
83
+ const wireType = key & 7;
84
+ let value;
85
+ if (wireType === 0) {
86
+ ({ value, pos } = _readVarint(buf, pos));
87
+ } else if (wireType === 2) {
88
+ let len;
89
+ ({ value: len, pos } = _readVarint(buf, pos));
90
+ if (pos + len > buf.length) break;
91
+ value = buf.slice(pos, pos + len);
92
+ pos += len;
93
+ } else if (wireType === 5) {
94
+ value = buf.readUInt32LE(pos); pos += 4;
95
+ } else if (wireType === 1) {
96
+ value = buf.slice(pos, pos + 8); pos += 8;
97
+ } else {
98
+ break; // groups: not used by these messages
99
+ }
100
+ if (!out.has(field)) out.set(field, []);
101
+ out.get(field).push(value);
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function _one(fields, n) {
107
+ const v = fields && fields.get(n);
108
+ return v && v.length ? v[0] : null;
109
+ }
110
+
111
+ // Walk a chain of message field numbers, e.g. msg(1).msg(2).msg(21).
112
+ function _walk(buf, path) {
113
+ let cur = buf;
114
+ for (const n of path) {
115
+ const next = _one(decodeFields(cur), n);
116
+ if (next === null || !Buffer.isBuffer(next)) return null;
117
+ cur = next;
118
+ }
119
+ return cur;
120
+ }
121
+
122
+ // ─── HTTP ─────────────────────────────────────────────────────────────────────
123
+
124
+ function _request(url, opts) {
125
+ opts = opts || {};
126
+ return new Promise((resolve, reject) => {
127
+ const u = new URL(url);
128
+ const req = https.request({
129
+ hostname: u.hostname,
130
+ port: u.port || 443,
131
+ path: u.pathname + u.search,
132
+ method: opts.method || 'GET',
133
+ headers: opts.headers || {},
134
+ timeout: REQUEST_TIMEOUT
135
+ }, (res) => {
136
+ // The CDN answers a signed URL with a redirect more often than not.
137
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location &&
138
+ (opts.redirects || 0) < 5) {
139
+ res.resume();
140
+ return resolve(_request(new URL(res.headers.location, url).toString(),
141
+ Object.assign({}, opts, { redirects: (opts.redirects || 0) + 1 })));
142
+ }
143
+ const chunks = [];
144
+ res.on('data', d => chunks.push(d));
145
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
146
+ });
147
+ req.on('error', reject);
148
+ req.on('timeout', () => req.destroy(new Error('timeout on ' + url)));
149
+ if (opts.body) req.write(opts.body);
150
+ req.end();
151
+ });
152
+ }
153
+
154
+ // ─── Auth ─────────────────────────────────────────────────────────────────────
155
+
156
+ async function fetchAnonymousAuth() {
157
+ const body = JSON.stringify(DEVICE_PROFILE);
158
+ const res = await _request(AURORA_DISPENSER_URL, {
159
+ method: 'POST',
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ 'User-Agent': AURORA_USER_AGENT,
163
+ 'Content-Length': Buffer.byteLength(body)
164
+ },
165
+ body
166
+ });
167
+ if (res.status !== 200) {
168
+ throw new Error('the Aurora token dispenser answered HTTP ' + res.status +
169
+ ' — it is a free third-party service and this is what it does when it is ' +
170
+ 'down or rate limiting. Nothing is wrong with your setup; either try again ' +
171
+ 'later or pass an APK path instead.');
172
+ }
173
+
174
+ let json;
175
+ try { json = JSON.parse(res.body.toString('utf8')); }
176
+ catch (_) { throw new Error('the Aurora token dispenser returned something that is not JSON'); }
177
+ if (!json || !json.authToken) {
178
+ throw new Error('the Aurora token dispenser returned no authToken');
179
+ }
180
+
181
+ const provider = json.deviceInfoProvider || {};
182
+ return {
183
+ authToken: json.authToken,
184
+ gsfId: json.gsfId || '',
185
+ dfeCookie: json.dfeCookie || '',
186
+ checkinConsistencyToken: json.deviceCheckInConsistencyToken || null,
187
+ deviceConfigToken: json.deviceConfigToken || null,
188
+ userAgent: provider.userAgentString || FALLBACK_FINSKY_USER_AGENT,
189
+ mccMnc: provider.mccMnc || null
190
+ };
191
+ }
192
+
193
+ function buildFdfeHeaders(auth) {
194
+ const headers = {
195
+ 'Authorization': 'Bearer ' + auth.authToken,
196
+ 'User-Agent': auth.userAgent,
197
+ 'X-DFE-Device-Id': auth.gsfId,
198
+ 'Accept-Language': 'en-US',
199
+ 'X-DFE-Encoded-Targets': X_DFE_ENCODED_TARGETS,
200
+ 'X-DFE-Client-Id': 'am-android-google',
201
+ 'X-DFE-Network-Type': '4',
202
+ 'X-DFE-Content-Filters': '',
203
+ 'X-Limit-Ad-Tracking-Enabled': 'false',
204
+ 'X-Ad-Id': '',
205
+ 'X-DFE-UserLanguages': LOCALE,
206
+ 'X-DFE-Request-Params': 'timeoutMs=4000',
207
+ 'X-DFE-Cookie': auth.dfeCookie,
208
+ 'X-DFE-No-Prefetch': 'true'
209
+ };
210
+ if (auth.checkinConsistencyToken) headers['X-DFE-Device-Checkin-Consistency-Token'] = auth.checkinConsistencyToken;
211
+ if (auth.deviceConfigToken) headers['X-DFE-Device-Config-Token'] = auth.deviceConfigToken;
212
+ if (auth.mccMnc) headers['X-DFE-MCCMNC'] = auth.mccMnc;
213
+ return headers;
214
+ }
215
+
216
+ async function _protoGet(url, headers) {
217
+ const res = await _request(url, {
218
+ headers: Object.assign({
219
+ 'Content-Type': 'application/x-protobuf',
220
+ 'Accept': 'application/x-protobuf'
221
+ }, headers)
222
+ });
223
+ if (res.status !== 200) throw new Error('GET ' + url + ' returned HTTP ' + res.status);
224
+ return res.body;
225
+ }
226
+
227
+ // ─── Catalogue ────────────────────────────────────────────────────────────────
228
+
229
+ // { versionCode, versionName } for a package, without downloading anything.
230
+ async function fetchVersion(packageName, headers) {
231
+ const raw = await _protoGet(DETAILS_URL + '?doc=' + encodeURIComponent(packageName), headers);
232
+ // ResponseWrapper(1).Payload → detailsResponse(2).docV2(4).docDetails(13).appDetails(1)
233
+ const details = _walk(raw, [1, 2, 4, 13, 1]);
234
+ if (!details) throw new Error('the Play details response carried no AppDetails for ' + packageName);
235
+ const fields = decodeFields(details);
236
+ const code = _one(fields, 3);
237
+ const name = _one(fields, 4);
238
+ if (code === null) throw new Error('the Play details response carried no versionCode for ' + packageName);
239
+ return { versionCode: code, versionName: name ? name.toString('utf8') : '' };
240
+ }
241
+
242
+ // The "Install" button. An app already associated with the rotated account
243
+ // answers 4xx, and the download works regardless, so failures are ignored.
244
+ async function acquire(packageName, versionCode, headers) {
245
+ const body = 'doc=' + encodeURIComponent(packageName) + '&ot=1&vc=' + versionCode;
246
+ try {
247
+ await _request(PURCHASE_URL, {
248
+ method: 'POST',
249
+ headers: Object.assign({
250
+ 'Content-Type': 'application/x-www-form-urlencoded',
251
+ 'Content-Length': Buffer.byteLength(body)
252
+ }, headers),
253
+ body
254
+ });
255
+ } catch (_) { /* best effort, as in Cobalt */ }
256
+ }
257
+
258
+ // { baseUrl, splits: [{name, url}], cookies: {name: value} }
259
+ async function fetchDelivery(packageName, versionCode, headers) {
260
+ const raw = await _protoGet(
261
+ DELIVERY_URL + '?doc=' + encodeURIComponent(packageName) + '&ot=1&vc=' + versionCode,
262
+ headers);
263
+ // ResponseWrapper(1).Payload → deliveryResponse(21).appDeliveryData(2)
264
+ const data = _walk(raw, [1, 21, 2]);
265
+ if (!data) throw new Error('the Play delivery response carried no AppDeliveryData for ' + packageName);
266
+
267
+ const fields = decodeFields(data);
268
+ const baseUrl = _one(fields, 3);
269
+ if (!baseUrl || !baseUrl.length) {
270
+ throw new Error('Play issued no download URL for ' + packageName +
271
+ ' — the app is unavailable for this synthetic device profile');
272
+ }
273
+
274
+ const splits = [];
275
+ for (const s of (fields.get(15) || [])) {
276
+ const sf = decodeFields(s);
277
+ const name = _one(sf, 1);
278
+ const url = _one(sf, 5);
279
+ if (!url || !url.length) continue;
280
+ splits.push({
281
+ name: name ? name.toString('utf8') : 'split-' + splits.length,
282
+ url: url.toString('utf8')
283
+ });
284
+ }
285
+
286
+ // Tag 4 is overloaded: cookies and OBB metadata share it. A cookie starts with
287
+ // the tag byte for a length-delimited field 1 (its name), which is what tells
288
+ // the two apart.
289
+ const cookies = {};
290
+ for (const entry of (fields.get(4) || [])) {
291
+ if (!entry.length || entry[0] !== 0x0a) continue;
292
+ const cf = decodeFields(entry);
293
+ const name = _one(cf, 1);
294
+ const value = _one(cf, 2);
295
+ if (!name || !name.length) continue;
296
+ cookies[name.toString('utf8')] = value ? value.toString('utf8') : '';
297
+ }
298
+
299
+ return { baseUrl: baseUrl.toString('utf8'), splits, cookies };
300
+ }
301
+
302
+ async function _download(url, cookies) {
303
+ const headers = {};
304
+ const pairs = Object.keys(cookies || {}).map(k => k + '=' + cookies[k]);
305
+ if (pairs.length) headers.Cookie = pairs.join('; ');
306
+ const res = await _request(url, { headers });
307
+ if (res.status < 200 || res.status >= 300) {
308
+ throw new Error('APK download failed with HTTP ' + res.status);
309
+ }
310
+ return res.body;
311
+ }
312
+
313
+ /**
314
+ * Download what the Android token material needs from Play.
315
+ *
316
+ * Returns { packageName, versionCode, versionName, base, splits: [{name,data}] }
317
+ * with the APK bytes in hand.
318
+ *
319
+ * Only the density splits are fetched. The token's key comes from
320
+ * about_logo.png, which lives in one of those; the architecture and language
321
+ * splits are a hundred megabytes that nothing here would ever read. Pass
322
+ * { allSplits: true } to take everything anyway.
323
+ */
324
+ async function downloadApk(opts) {
325
+ opts = opts || {};
326
+ const packageName = opts.packageName || PERSONAL_PACKAGE;
327
+
328
+ _whaDbg('[DBG] PLAY → dispenser');
329
+ const auth = await fetchAnonymousAuth();
330
+ const headers = buildFdfeHeaders(auth);
331
+
332
+ _whaDbg('[DBG] PLAY → details ' + packageName);
333
+ const version = await fetchVersion(packageName, headers);
334
+ if (opts.onProgress) opts.onProgress('version ' + version.versionName + ' (code ' + version.versionCode + ')');
335
+
336
+ await acquire(packageName, version.versionCode, headers);
337
+
338
+ _whaDbg('[DBG] PLAY → delivery ' + packageName + ' vc=' + version.versionCode);
339
+ const delivery = await fetchDelivery(packageName, version.versionCode, headers);
340
+
341
+ const wanted = opts.allSplits
342
+ ? delivery.splits
343
+ : delivery.splits.filter(s => /dpi$/i.test(String(s.name).replace(/\.apk$/i, '')));
344
+
345
+ // Which densities came back is worth saying out loud. Play serves the splits
346
+ // that suit the device profile it was asked as, not the full set a bundle
347
+ // holds, so the about_logo.png this yields can be a different density from
348
+ // the one in a complete bundle — and a different density is a different key.
349
+ if (opts.onProgress) {
350
+ opts.onProgress('downloading base.apk and ' + wanted.length + ' density split' +
351
+ (wanted.length === 1 ? '' : 's') +
352
+ ' (of ' + delivery.splits.length + ' the server offered): ' +
353
+ (wanted.map(s => s.name).join(', ') || 'none'));
354
+ }
355
+
356
+ const base = await _download(delivery.baseUrl, delivery.cookies);
357
+ const splits = [];
358
+ for (const s of wanted) {
359
+ if (opts.onProgress) opts.onProgress(' ' + s.name);
360
+ splits.push({ name: s.name, data: await _download(s.url, delivery.cookies) });
361
+ }
362
+
363
+ return {
364
+ packageName,
365
+ versionCode: version.versionCode,
366
+ versionName: version.versionName,
367
+ base,
368
+ splits
369
+ };
370
+ }
371
+
372
+ module.exports = {
373
+ downloadApk,
374
+ fetchAnonymousAuth,
375
+ buildFdfeHeaders,
376
+ fetchVersion,
377
+ fetchDelivery,
378
+ decodeFields,
379
+ AURORA_DISPENSER_URL,
380
+ PERSONAL_PACKAGE
381
+ };
@@ -137,7 +137,7 @@ function socksProxyUrl() {
137
137
  return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
138
138
  }
139
139
 
140
- async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
140
+ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
141
141
  const proxy = parseSocksProxy(proxyUrl);
142
142
  const dHost = 'v.whatsapp.net';
143
143
  const dPort = 443;
@@ -164,7 +164,7 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
164
164
  tlsSocket.once('error', rej);
165
165
  });
166
166
 
167
- const headers = registrationHeaders(getDeviceConfig(), waVersion);
167
+ const headers = registrationHeaders(device || getDeviceConfig(), waVersion);
168
168
  const reqLines = [
169
169
  `POST /v2${path} HTTP/1.1`,
170
170
  `Host: ${dHost}`,
@@ -575,6 +575,62 @@ function tryLoadAndroidMaterial() {
575
575
  try { return loadAndroidMaterial(); } catch (_) { return null; }
576
576
  }
577
577
 
578
+ /**
579
+ * Have the Android token material ready, fetching the APK if it is not.
580
+ *
581
+ * Registering as Android cannot produce a token without material read out of a
582
+ * WhatsApp APK, and that material goes stale on every WhatsApp release. Asking
583
+ * the caller to go and find an APK first turns one command into a chore, so a
584
+ * registration that finds none fetches one from Play itself and carries on.
585
+ *
586
+ * Only a *missing* file is answered this way. An APK that turns out to be a
587
+ * repack is refused as it was before — downloading over somebody's deliberate
588
+ * choice of APK would be the wrong way to be helpful.
589
+ *
590
+ * A no-op on iOS, and WA_NO_APK_DOWNLOAD=1 turns it off for anyone who would
591
+ * rather a hundred megabytes were never fetched without being asked for.
592
+ */
593
+ async function ensureAndroidMaterial(opts, device) {
594
+ opts = opts || {};
595
+ device = device || getDeviceConfig();
596
+ if (device.os !== 'android') return null;
597
+
598
+ try {
599
+ return loadAndroidMaterial();
600
+ } catch (err) {
601
+ if (err.code !== 'NO_APK_MATERIAL') throw err;
602
+ if (process.env.WA_NO_APK_DOWNLOAD === '1') throw err;
603
+ }
604
+
605
+ const say = opts.onProgress || (m => _whaDbg('[DBG] APK ' + m));
606
+ const AndroidApk = require('./AndroidApk');
607
+ const PlayStore = require('./PlayStore');
608
+
609
+ say('no Android token material yet — fetching the WhatsApp APK from Google Play');
610
+ let apk;
611
+ try {
612
+ apk = await PlayStore.downloadApk({ onProgress: m => say(' ' + m) });
613
+ } catch (err) {
614
+ throw new Error('could not fetch the WhatsApp APK automatically: ' + err.message +
615
+ '\nRead it out of an APK you have instead:\n' +
616
+ ' wa apk-material <base.apk> [split.apk ...]');
617
+ }
618
+
619
+ const material = AndroidApk.extractMaterial(apk.base, apk.splits);
620
+ if (!material.apkVersion) material.apkVersion = apk.versionName;
621
+ if (!material.apkVersionCode) material.apkVersionCode = apk.versionCode;
622
+
623
+ const file = androidMaterialPath();
624
+ fs.mkdirSync(path.dirname(file), { recursive: true });
625
+ fs.writeFileSync(file, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
626
+ say(' token material written to ' + file);
627
+
628
+ // Re-read it rather than using what is in hand, so the downloaded APK goes
629
+ // through the same signature check as one that was already on disk.
630
+ _androidMaterial = null;
631
+ return loadAndroidMaterial();
632
+ }
633
+
578
634
  function loadAndroidMaterial() {
579
635
  if (_androidMaterial) return _androidMaterial;
580
636
  const file = androidMaterialPath();
@@ -582,18 +638,46 @@ function loadAndroidMaterial() {
582
638
  try {
583
639
  raw = fs.readFileSync(file, 'utf8');
584
640
  } 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' +
641
+ const err = new Error('registering as Android needs the token material from a WhatsApp APK, ' +
642
+ 'and there is none at ' + file + '. Fetch it with:\n' +
643
+ ' wa apk-material --download\n' +
644
+ 'or read it out of an APK you already have:\n' +
587
645
  ' 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.');
646
+ 'Registering as iOS needs none of this.');
647
+ // Told apart from a refused repack, which must never be answered by
648
+ // downloading over it.
649
+ err.code = 'NO_APK_MATERIAL';
650
+ throw err;
590
651
  }
591
- _androidMaterial = AndroidApk.materialFromJson(JSON.parse(raw));
652
+ const material = AndroidApk.materialFromJson(JSON.parse(raw));
653
+
654
+ // Refuse a repack before anything is sent.
655
+ //
656
+ // The token is an HMAC over the APK's signing certificates. An APK a mirror
657
+ // re-signed — commonly with the AOSP test key, whose private half ships in the
658
+ // Android sources — produces a well-formed token that belongs to nobody, and
659
+ // the server answers bad_token, exactly as it does when there is no material
660
+ // at all. Left to run, that spends /code attempts against a real phone number
661
+ // on a request that cannot succeed, and attempts are what gets a number rate
662
+ // limited. Checking the subject costs nothing and is the difference between a
663
+ // refusal here and a number burned.
664
+ const signer = AndroidApk.describeCertificate(material.certificates[0]);
665
+ if (signer && !AndroidApk.looksLikeWhatsAppCertificate(signer) &&
666
+ process.env.WA_ALLOW_FOREIGN_APK !== '1') {
667
+ throw new Error('the APK this token material came from was not signed by WhatsApp — ' +
668
+ 'it is signed by "' + signer.subject.replace(/\n/g, ', ') + '". Re-signing replaces ' +
669
+ 'the certificate the token is built from, so every attempt would come back as ' +
670
+ 'bad_token. Take the APK from a phone that has WhatsApp installed:\n' +
671
+ ' pm path com.whatsapp\n' +
672
+ 'then re-run wa apk-material with it. Set WA_ALLOW_FOREIGN_APK=1 to send it anyway.');
673
+ }
674
+
675
+ _androidMaterial = material;
592
676
  return _androidMaterial;
593
677
  }
594
678
 
595
- function computeToken(waVersion, national) {
596
- const device = getDeviceConfig();
679
+ function computeToken(waVersion, national, device) {
680
+ device = device || getDeviceConfig();
597
681
  if (device.os === 'android') {
598
682
  return AndroidApk.computeToken(loadAndroidMaterial(), national);
599
683
  }
@@ -733,8 +817,8 @@ function toBase64Url(buf) {
733
817
  async function buildPayload(store, waVersion, useToken, extraPairs) {
734
818
  const { cc, national } = parsePhone(store.phoneNumber);
735
819
  const meta = getCountryMeta(cc);
736
- const token = useToken ? computeToken(waVersion, national) : null;
737
820
  const device = store.device || getDeviceConfig();
821
+ const token = useToken ? computeToken(waVersion, national, device) : null;
738
822
  // Android formats fdid lowercase, iOS uppercase (matches the native clients).
739
823
  const fdid = device.os === 'android'
740
824
  ? store.fdid.toLowerCase()
@@ -790,14 +874,14 @@ function encryptPayload(plaintext) {
790
874
 
791
875
  // ---------- HTTP ----------
792
876
 
793
- function httpPost(path, body, waVersion, authHeader) {
877
+ function httpPost(path, body, waVersion, authHeader, device) {
794
878
  const proxy = socksProxyUrl();
795
- if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader);
879
+ if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device);
796
880
 
797
881
  return new Promise((resolve, reject) => {
798
882
  const bodyBuf = Buffer.from(body, 'utf8');
799
883
  const headers = Object.assign(
800
- registrationHeaders(getDeviceConfig(), waVersion),
884
+ registrationHeaders(device || getDeviceConfig(), waVersion),
801
885
  { 'Content-Length': bodyBuf.length }
802
886
  );
803
887
  // Attestation cert-chain / App Attest header (Android cert chain, iOS
@@ -859,7 +943,7 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
859
943
 
860
944
  _whaDbg('[DBG] REG → ' + path + ' (' + body.length + ' bytes' +
861
945
  (bodyAtt.bodyAttestation ? ', attested' : '') + ')');
862
- const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
946
+ const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device);
863
947
  _whaDbg('[DBG] REG ← ' + path + ' ' +
864
948
  (result && result.status ? result.status : JSON.stringify(result).slice(0, 120)) +
865
949
  (result && result.reason ? ' reason=' + result.reason : ''));
@@ -1109,7 +1193,7 @@ async function handle2FA(store, waVersion, opts) {
1109
1193
  // ---------- Public API ----------
1110
1194
 
1111
1195
  async function checkIfRegistered(store) {
1112
- const waVersion = await fetchWaVersion(getDeviceConfig());
1196
+ const waVersion = await fetchWaVersion(store.device || getDeviceConfig());
1113
1197
  return sendRequest('/exist', store, waVersion, false, null);
1114
1198
  }
1115
1199
 
@@ -1247,7 +1331,14 @@ async function assertRegistrationKeys(store, waVersion) {
1247
1331
  async function requestSmsCode(store, method, opts) {
1248
1332
  method = method || 'sms';
1249
1333
  opts = opts || {};
1250
- const _device = getDeviceConfig();
1334
+ // Before the version is read, since on Android the version comes out of the
1335
+ // APK this fetches.
1336
+ // What a session registered as is settled when it is created, so it is the
1337
+ // store that decides — not whatever WA_OS the shell happens to carry now. A
1338
+ // confirmation run from a shell without the variables would otherwise sign an
1339
+ // Android registration with an iOS token.
1340
+ const _device = store.device || getDeviceConfig();
1341
+ await ensureAndroidMaterial(opts, _device);
1251
1342
  const waVersion = await fetchWaVersion(_device);
1252
1343
  store.version = waVersion;
1253
1344
  store.device = _device;
@@ -1366,7 +1457,14 @@ async function requestSmsCode(store, method, opts) {
1366
1457
 
1367
1458
  async function verifyCode(store, code, opts) {
1368
1459
  opts = opts || {};
1369
- const _device = getDeviceConfig();
1460
+ // Normally already there from the code request, but a confirmation can be run
1461
+ // from a fresh shell that never made one.
1462
+ // What a session registered as is settled when it is created, so it is the
1463
+ // store that decides — not whatever WA_OS the shell happens to carry now. A
1464
+ // confirmation run from a shell without the variables would otherwise sign an
1465
+ // Android registration with an iOS token.
1466
+ const _device = store.device || getDeviceConfig();
1467
+ await ensureAndroidMaterial(opts, _device);
1370
1468
  const waVersion = await fetchWaVersion(_device);
1371
1469
  store.version = waVersion;
1372
1470
  store.device = _device;
@@ -1461,7 +1559,7 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
1461
1559
  module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
1462
1560
 
1463
1561
  // Token computation, exposed for tests. Not part of the public API.
1464
- module.exports._token = { computeToken, androidMaterialPath, registrationHeaders };
1562
+ module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial };
1465
1563
 
1466
1564
  // Challenge / two-factor internals, exposed for tests. Not part of the public API.
1467
1565
  module.exports._verify = {
@@ -0,0 +1,45 @@
1
+ // Aurora-style device profile posted to the anonymous token dispenser.
2
+ // Copied verbatim from Cobalt's sdk-gphone64-x86_64.json — the dispenser
3
+ // mints an account shaped for this device, and the Play catalogue decides
4
+ // what it is allowed to download from it.
5
+
6
+ 'use strict';
7
+
8
+ module.exports = {
9
+ "HasHardKeyboard": "true",
10
+ "Build.HARDWARE": "ranchu",
11
+ "Build.BRAND": "google",
12
+ "Build.VERSION.SDK_INT": "33",
13
+ "Roaming": "mobile-notroaming",
14
+ "Build.MODEL": "sdk_gphone64_x86_64",
15
+ "Vending.versionString": "51.0.19-31 [0] [PR] 897988648",
16
+ "Vending.version": "85101930",
17
+ "CellOperator": "310",
18
+ "Build.FINGERPRINT": "google/sdk_gphone64_x86_64/emu64x:13/TE1A.240213.009/12342917:user/release-keys",
19
+ "Build.DEVICE": "emu64x",
20
+ "Screen.Height": "3120",
21
+ "SimOperator": "260",
22
+ "ScreenLayout": "2",
23
+ "SharedLibraries": "android.ext.shared,android.hidl.base-V1.0-java,android.hidl.manager-V1.0-java,android.net.ipsec.ike,android.test.base,android.test.mock,android.test.runner,androidx.window.extensions,androidx.window.sidecar,com.android.cts.ctsshim.shared_library,com.android.future.usb.accessory,com.android.location.provider,com.android.media.remotedisplay,com.android.mediadrm.signer,com.android.nfc_extras,com.google.android.dialer.support,com.google.android.gms,com.google.android.trichromelibrary,javax.obex,org.apache.http.legacy",
24
+ "Features": "android.hardware.audio.output,android.hardware.bluetooth,android.hardware.bluetooth_le,android.hardware.camera,android.hardware.camera.any,android.hardware.camera.ar,android.hardware.camera.autofocus,android.hardware.camera.capability.manual_post_processing,android.hardware.camera.capability.manual_sensor,android.hardware.camera.capability.raw,android.hardware.camera.concurrent,android.hardware.camera.flash,android.hardware.camera.front,android.hardware.camera.level.full,android.hardware.faketouch,android.hardware.fingerprint,android.hardware.hardware_keystore,android.hardware.identity_credential,android.hardware.keystore.app_attest_key,android.hardware.location,android.hardware.location.gps,android.hardware.location.network,android.hardware.microphone,android.hardware.ram.normal,android.hardware.screen.landscape,android.hardware.screen.portrait,android.hardware.security.model.compatible,android.hardware.sensor.accelerometer,android.hardware.sensor.ambient_temperature,android.hardware.sensor.barometer,android.hardware.sensor.compass,android.hardware.sensor.gyroscope,android.hardware.sensor.hinge_angle,android.hardware.sensor.light,android.hardware.sensor.proximity,android.hardware.sensor.relative_humidity,android.hardware.telephony,android.hardware.telephony.data,android.hardware.telephony.gsm,android.hardware.telephony.ims,android.hardware.telephony.radio.access,android.hardware.telephony.subscription,android.hardware.touchscreen,android.hardware.touchscreen.multitouch,android.hardware.touchscreen.multitouch.distinct,android.hardware.touchscreen.multitouch.jazzhand,android.hardware.vulkan.compute,android.hardware.vulkan.level,android.hardware.vulkan.version,android.hardware.wifi,android.hardware.wifi.direct,android.hardware.wifi.passpoint,android.software.activities_on_secondary_displays,android.software.adoptable_storage,android.software.app_enumeration,android.software.app_widgets,android.software.autofill,android.software.backup,android.software.cant_save_state,android.software.companion_device_setup,android.software.controls,android.software.cts,android.software.device_admin,android.software.erofs,android.software.file_based_encryption,android.software.home_screen,android.software.incremental_delivery,android.software.input_methods,android.software.ipsec_tunnels,android.software.live_wallpaper,android.software.managed_users,android.software.midi,android.software.opengles.deqp.level,android.software.picture_in_picture,android.software.print,android.software.secure_lock_screen,android.software.securely_removes_users,android.software.telecom,android.software.verified_boot,android.software.voice_recognizers,android.software.vulkan.deqp.level,android.software.webview,com.google.android.apps.dialer.SUPPORTED,com.google.android.feature.EXCHANGE_6_2,com.google.android.feature.GOOGLE_BUILD,com.google.android.feature.GOOGLE_EXPERIENCE,com.google.android.feature.WELLBEING",
25
+ "GSF.version": "33",
26
+ "UserReadableName": "Android Emulator x86_64 (API 33)",
27
+ "Build.RADIO": "1.0.0.0",
28
+ "Client": "android-google",
29
+ "Locales": "af,af_ZA,am,am_ET,ar,ar_EG,ar_XB,as,az,be,bg,bg_BG,bn,bs,ca,ca_ES,cs,cs_CZ,da,da_DK,de,de_DE,el,el_GR,en,en_AU,en_CA,en_GB,en_IN,en_US,en_XA,es,es_ES,es_US,et,eu,fa,fa_IR,fi,fi_FI,fr,fr_CA,fr_FR,gl,gu,hi,hi_IN,hr,hr_HR,hu,hu_HU,hy,in,in_ID,is,it,it_IT,iw,iw_IL,ja,ja_JP,ka,kk,km,kn,ko,ko_KR,ky,lo,lt,lt_LT,lv,lv_LV,mk,ml,mn,mr,ms,ms_MY,my,nb,nb_NO,ne,nl,nl_NL,or,pa,pl,pl_PL,pt,pt_BR,pt_PT,ro,ro_RO,ru,ru_RU,si,sk,sk_SK,sl,sl_SI,sq,sr,sr_RS,sv,sv_SE,sw,sw_TZ,ta,te,th,th_TH,tl,tl_PH,tr,tr_TR,uk,uk_UA,ur,uz,vi,vi_VN,zh_CN,zh_HK,zh_TW,zu,zu_ZA",
30
+ "Screen.Density": "560",
31
+ "Platforms": "x86_64",
32
+ "Build.PRODUCT": "sdk_gphone64_x86_64",
33
+ "Navigation": "1",
34
+ "Keyboard": "2",
35
+ "Build.ID": "TE1A.240213.009",
36
+ "TimeZone": "UTC+1",
37
+ "Screen.Width": "1440",
38
+ "GL.Extensions": "GL_APPLE_texture_format_BGRA8888,GL_EXT_color_buffer_float,GL_EXT_color_buffer_half_float,GL_EXT_debug_marker,GL_EXT_robustness,GL_EXT_texture_format_BGRA8888,GL_KHR_texture_compression_astc_ldr,GL_OES_EGL_image,GL_OES_EGL_image_external,GL_OES_EGL_image_external_essl3,GL_OES_EGL_sync,GL_OES_compressed_ETC1_RGB8_texture,GL_OES_compressed_paletted_texture,GL_OES_depth24,GL_OES_depth32,GL_OES_depth_texture,GL_OES_element_index_uint,GL_OES_packed_depth_stencil,GL_OES_rgb8_rgba8,GL_OES_standard_derivatives,GL_OES_texture_float,GL_OES_texture_float_linear,GL_OES_texture_half_float,GL_OES_texture_half_float_linear,GL_OES_texture_npot,GL_OES_vertex_array_object,GL_OES_vertex_half_float",
39
+ "HasFiveWayNavigation": "false",
40
+ "TouchScreen": "3",
41
+ "GL.Version": "196608",
42
+ "Build.VERSION.RELEASE": "13",
43
+ "Build.BOOTLOADER": "unknown",
44
+ "Build.MANUFACTURER": "Google"
45
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.12.11",
4
- "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
3
+ "version": "5.12.14",
4
+ "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",
7
7
  "bin": {