whalibmob 5.12.17 → 5.13.3

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
@@ -4,7 +4,10 @@
4
4
  #
5
5
  # cp node_modules/whalibmob/.env.example .env
6
6
  #
7
- # The CLI loads `.env` from the current working directory automatically.
7
+ # The CLI loads `.env` from the current working directory automatically — from
8
+ # the directory the command runs in, not from where whalibmob is installed. Run
9
+ # `wa` somewhere else and this file does not apply; export the variables in your
10
+ # shell profile instead if you want them everywhere.
8
11
  # If you use the library programmatically, call `require('dotenv').config()`
9
12
  # yourself before creating a client.
10
13
  #
@@ -17,6 +20,19 @@
17
20
  # Values: ios | android
18
21
  # WA_OS=ios
19
22
 
23
+ # Register as WhatsApp Business instead of the consumer app.
24
+ # Values: 1 | true | yes | on (anything else, or unset, means the consumer app)
25
+ #
26
+ # This has to be set before the code is requested: an account is created as
27
+ # Business or as consumer, and the token, the User-Agent and the verified-name
28
+ # certificate all have to keep saying which. A session already part-way through
29
+ # registering keeps what it started as and says so.
30
+ #
31
+ # On Android the token material is read from com.whatsapp.w4b rather than
32
+ # com.whatsapp, and is cached in its own file, so the two never overwrite each
33
+ # other. On iOS the version comes from the Business App Store listing.
34
+ # WA_BUSINESS=1
35
+
20
36
  # Named predefined device profile. Takes priority over the individual
21
37
  # WA_DEVICE_* variables below.
22
38
  #
@@ -51,15 +67,25 @@
51
67
 
52
68
 
53
69
  # ─── WhatsApp version ────────────────────────────────────────────────────────
54
- # Pin the WhatsApp client version. When set, the live version lookup is skipped
55
- # entirely, and this is what is announced on connect in place of the version
56
- # stored in the session. Leave unset to fetch the current version automatically.
57
- #
58
- # On Android this is normally not needed: the version comes from the APK the
59
- # token material was read from. On iOS it is looked up on the App Store, and a
60
- # lookup that fails leaves a built-in fallback version in the session — which
61
- # the server refuses at connect with a 405. That is what this is for.
62
- # WA_VERSION=2.24.10.75
70
+ # LEAVE THIS UNSET. It is a temporary override, not a setting.
71
+ #
72
+ # When set, the live version lookup is skipped and this value is announced on
73
+ # connect in place of the version the session was registered with — every
74
+ # connect started from this directory, for every number, until it is removed.
75
+ # The version WhatsApp accepts moves on every few weeks, so a value left here
76
+ # eventually refuses every connect with `<failure reason="405">`, on sessions
77
+ # that were working the day before. It is the single most common cause of that
78
+ # error.
79
+ #
80
+ # On Android it is never needed: the version comes out of the APK the
81
+ # registration token was signed over, which is the build the token actually
82
+ # proves. Pinning something else here makes the two describe different builds.
83
+ #
84
+ # On iOS it is the escape hatch: the version is looked up on the App Store, and
85
+ # a lookup that times out leaves a built-in fallback in the session that the
86
+ # server may refuse. Pin a current version, connect, then remove the line —
87
+ # uncomment this only when you have a 405 and a version to put in it.
88
+ # WA_VERSION=
63
89
 
64
90
  # Override the static registration token. iOS only — Android has no static token
65
91
  # and signs its own out of the APK (see below). Leave unset to use the built-in
package/README.md CHANGED
@@ -121,6 +121,7 @@ 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
+ - [Registering a WhatsApp Business account](#registering-a-whatsapp-business-account)
124
125
  - [Why an APK is involved at all](#why-an-apk-is-involved-at-all)
125
126
  - [Fetching the APK on its own](#fetching-the-apk-on-its-own)
126
127
  - [Reading it out of an APK you already have](#reading-it-out-of-an-apk-you-already-have)
@@ -1644,6 +1645,47 @@ unasked, and `wa apk-material` below does the same job by hand.
1644
1645
  The rest of this section is what happens behind that one command, and how to
1645
1646
  drive each part yourself.
1646
1647
 
1648
+ ### Registering a WhatsApp Business account
1649
+
1650
+ Set `WA_BUSINESS`, or pass `--business`, and the whole registration switches to
1651
+ the Business build:
1652
+
1653
+ ```sh
1654
+ WA_OS=android WA_BUSINESS=1 wa registration --request-code 919634847671
1655
+ wa registration --register 919634847671 --code 123456
1656
+ wa connect 919634847671
1657
+ ```
1658
+
1659
+ Everything that names the app follows from that one variable:
1660
+
1661
+ | | consumer | Business |
1662
+ |---|---|---|
1663
+ | announced platform | `ANDROID` / `IOS` | `ANDROID_BUSINESS` / `IOS_BUSINESS` |
1664
+ | User-Agent | `Android/…` · `iOS/…` | `SMBA/…` · `SMB iOS/…` |
1665
+ | Android token material | `com.whatsapp` | `com.whatsapp.w4b` |
1666
+ | iOS token constant | consumer | Business |
1667
+ | version lookup | consumer listing | Business listing |
1668
+ | `vname` field | not sent | a self-signed verified-name certificate |
1669
+ | Frida attestation port | 1119 | 1120 |
1670
+
1671
+ The Android token material lives in its own file
1672
+ (`android-apk-material-business.json`), so the two builds never overwrite each
1673
+ other and `wa apk-material --download --business` can sit beside the consumer
1674
+ one.
1675
+
1676
+ `vname` is a `VerifiedNameCertificate` the client signs itself, carrying an
1677
+ empty name, the issuer `smb:wa` and a random serial. The name is empty because
1678
+ nothing is verified yet — WhatsApp issues the real one after it reviews the
1679
+ business. What the server checks is that the signature over those details was
1680
+ made with the identity key the same request registers.
1681
+
1682
+ > [!IMPORTANT]
1683
+ > **Decide before the code goes out.** An account is created as Business or as
1684
+ > consumer, and the token, the User-Agent and the certificate all have to keep
1685
+ > saying which. A session that is already part-way through registering keeps
1686
+ > what it started as and says so rather than flipping halfway; delete the
1687
+ > session file to start it over as the other one.
1688
+
1647
1689
  #### Why an APK is involved at all
1648
1690
 
1649
1691
  The registration token is computed differently on each platform, and only iOS
@@ -4477,7 +4519,8 @@ These variables override individual fields on top of the selected profile:
4477
4519
  | Variable | Description |
4478
4520
  |---|---|
4479
4521
  | `WA_VERSION` | Pin the WhatsApp version (e.g. `2.24.13.80`). Skips the live store fetch, and is announced on connect **in place of the version stored in the session**. The CLI also reads it from a `.env` file in the working directory, so one left there is announced by every connect from that directory — which is how a working session starts being refused with [405](#when-the-server-answers-405-on-connect). Pin it deliberately, unset it when done. |
4480
- | `WA_STATIC_TOKEN` | Override the static token used in registration token computation. iOS only — Android has no static token. |
4522
+ | `WA_STATIC_TOKEN` | Override the static token used in registration token computation. iOS only — Android has no static token. Overrides both the consumer and the Business constant. |
4523
+ | `WA_BUSINESS` | Register and connect as WhatsApp Business (`1`/`true`/`yes`/`on`). Decides the announced platform, the User-Agent, which APK the token material comes from, and the `vname` certificate. See [Registering a WhatsApp Business account](#registering-a-whatsapp-business-account). |
4481
4524
 
4482
4525
  ### When the server answers 405 on connect
4483
4526
 
@@ -4547,13 +4590,54 @@ each platform learns that differently:
4547
4590
  | iOS | looked up on the App Store | falls back to a version compiled into the library, which goes stale |
4548
4591
  | Android | read from the APK the registration token material came from | `wa apk-material --download` fetches the current one |
4549
4592
 
4550
- So a genuinely stale session version is mostly an iOS story: a lookup that times
4551
- out or is rate limited leaves an old fallback in the session, and nothing says so
4552
- until the handshake is refused. On Android the version travels with the APK, and
4553
- refreshing the material refreshes the version with it:
4593
+ **That version is written once, at registration, and nothing else ever touches
4594
+ it.** A number registered today announces today's version next year too, and one
4595
+ day the server stops accepting it — a 405 with nothing wrong with the account.
4596
+ Refreshing the APK material does not reach the sessions already on disk.
4597
+
4598
+ `wa refresh-version` is the part that does:
4554
4599
 
4555
4600
  ```sh
4556
- wa apk-material --download
4601
+ wa apk-material --download # Android: pick up the current APK first
4602
+ wa refresh-version 5568936182750 # then write its version into the session
4603
+ ```
4604
+
4605
+ ```
4606
+ +5568936182750 android 2.24.10.75 → 2.26.30.5
4607
+
4608
+ 1 session(s) updated
4609
+ read from the APK the token material came from.
4610
+ reconnect for it to be announced.
4611
+ ```
4612
+
4613
+ It touches `version` and nothing else — the keys, the device profile and the
4614
+ registration are left exactly as they were — and it never moves a session
4615
+ backwards. The store it reads from can be behind what a session already holds,
4616
+ since Play serves the build matching the device profile it was asked with rather
4617
+ than the newest on the listing, and announcing an older version is the one
4618
+ outcome that makes a 405 more likely:
4619
+
4620
+ ```
4621
+ +40756218532 android 2.26.30.3 (kept — newer than the 2.26.29.73 available)
4622
+ ``` `--all` does every session in the
4623
+ session directory, which is what belongs in a monthly cron for a bot that is
4624
+ meant to stay up:
4625
+
4626
+ ```sh
4627
+ wa apk-material --download && wa refresh-version --all
4628
+ ```
4629
+
4630
+ `--version 2.26.30.5` writes one you name instead of looking one up. And if
4631
+ `WA_VERSION` is set, the command says so — the override would mask whatever it
4632
+ writes.
4633
+
4634
+ From Node, the same thing:
4635
+
4636
+ ```js
4637
+ const { refreshSessionVersion, currentVersionFor } = require('whalibmob')
4638
+
4639
+ await refreshSessionVersion('/home/you/.waSession/5568936182750.json')
4640
+ await currentVersionFor({ os: 'android' }) // { version, source }
4557
4641
  ```
4558
4642
 
4559
4643
  #### If every Android session is refused, whatever the version
@@ -4569,14 +4653,17 @@ Sessions written before the fix repair themselves the next time they are loaded
4569
4653
  — the platform is derived from the profile's `os` rather than trusted from the
4570
4654
  file — so nothing has to be registered again.
4571
4655
 
4572
- 5.12.16 also brought the rest of the Android handshake in line with what a
4573
- native Android client announces: no carrier (`mcc` and `mnc` are `000`), `en`
4574
- and `US` as the locale, no `osBuildNumber`, the model rather than the model id
4575
- as the device name, an uppercase `phoneId`, and `shortConnect`, `connectType`,
4576
- `connectReason` and `connectAttemptCount` fixed at the values a real client
4577
- sends on every connect — `connectType` had been sending `3`, which is not in the
4578
- enum at all. iOS announces the real carrier and locale, which it has always been
4579
- accepted with, and is unchanged.
4656
+ One other field in the same payload was wrong rather than merely unusual:
4657
+ `connectType` sent `3` on every reconnect, and the enum has no `3` — the legal
4658
+ values are `0` (cellular, unknown radio), `1` (wifi) and `100`–`112` for the
4659
+ named cellular radios. It now sends `1`.
4660
+
4661
+ Nothing else in the payload changed. Other clients announce no carrier
4662
+ (`mcc`/`mnc` as `000`) and `en`/`US` regardless of the number; this library
4663
+ announces the carrier and locale the number actually belongs to, and a live
4664
+ session was tried against the server both ways — neither is refused. `000/000`
4665
+ is what a handset with no SIM reports, so a number with a carrier behind it
4666
+ saying so is the more ordinary thing to be.
4580
4667
 
4581
4668
  ### Finding out what a 405 objects to
4582
4669
 
@@ -4592,21 +4679,22 @@ node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
4592
4679
  ```
4593
4680
 
4594
4681
  ```
4595
- reference shape, as-is ok — LOGIN ACCEPTED
4596
- + the real carrier (mcc/mnc) ok — LOGIN ACCEPTED
4597
- + the real locale 405 {"reason":"405"}
4682
+ what this library sends ok — LOGIN ACCEPTED
4683
+ without the carrier (000/000) ok — LOGIN ACCEPTED
4684
+ without the locale (en/US) ok — LOGIN ACCEPTED
4598
4685
  ```
4599
4686
 
4600
4687
  `405` means that row was refused, `401` means the client was accepted and only
4601
- the credentials failed, `ok` means the login went through — so the first row
4602
- that stops saying `ok` names the field the server objected to. Every row uses
4603
- the session already on disk: nothing is registered, no code is requested, and
4604
- the session is never written to. `--dry-run` prints the payload sizes without
4605
- opening a socket.
4606
-
4607
- If *every* row is accepted while `wa connect` is refused, the payload is not the
4608
- problem and the difference is in the environment the CLI reads and the tool does
4609
- not — which is `WA_VERSION`, and the top of this section.
4688
+ the credentials failed, `ok` means the login went through. The first row is what
4689
+ a real connect puts on the wire, and each row after it changes exactly one
4690
+ field, so a row that behaves differently from the first names the field the
4691
+ server objected to. Every row uses the session already on disk: nothing is
4692
+ registered, no code is requested, and the session is never written to.
4693
+ `--dry-run` prints the payload sizes without opening a socket.
4694
+
4695
+ **If the first row is accepted while `wa connect` is refused, the payload is not
4696
+ the problem.** The difference is then in what the CLI reads and this tool does
4697
+ not — `WA_VERSION`, from `.env`. Go back to the top of this section.
4610
4698
 
4611
4699
  ## License
4612
4700
 
package/cli.js CHANGED
@@ -2358,6 +2358,7 @@ usage:
2358
2358
  wa registration --check <phone>
2359
2359
  wa apk-material <base.apk> [split.apk ...] read the Android token material
2360
2360
  wa apk-material --download fetch that APK from Google Play
2361
+ wa refresh-version <phone> update the version a session announces
2361
2362
  wa version
2362
2363
 
2363
2364
  options:
@@ -2367,6 +2368,9 @@ options:
2367
2368
  --pair connect by linking to an existing account (8-digit code)
2368
2369
  --method sms | voice | wa_old | email (default: sms)
2369
2370
  --email <address> email address (required when --method email)
2371
+ --business register/connect as WhatsApp Business (same as WA_BUSINESS=1)
2372
+ --all refresh-version: every session in the session directory
2373
+ --version <x> refresh-version: write this version instead of looking one up
2370
2374
 
2371
2375
  debug:
2372
2376
  an interactive session asks once whether to trace the protocol.
@@ -2391,7 +2395,11 @@ function announceTrace() {
2391
2395
  function askDebugMode(cmd) {
2392
2396
  if (TRACE_FORCE_ON) { enableWireTrace(); announceTrace(); return Promise.resolve(); }
2393
2397
  if (TRACE_FORCE_OFF) return Promise.resolve();
2394
- const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h'];
2398
+ // Commands that never open a WhatsApp connection: there is no wire for a
2399
+ // trace to show, and stopping a maintenance command to ask is what makes
2400
+ // `wa apk-material --download && wa refresh-version --all` prompt twice.
2401
+ const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
2402
+ 'apk-material', 'refresh-version'];
2395
2403
  if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
2396
2404
  if (!process.stdin.isTTY) return Promise.resolve();
2397
2405
 
@@ -2413,7 +2421,11 @@ function askDebugMode(cmd) {
2413
2421
  // WA_NO_DONATE=1 all skip it in silence, so nothing about it can get in the way
2414
2422
  // of a script or a real command.
2415
2423
  function askDonation(cmd) {
2416
- const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h'];
2424
+ // Commands that never open a WhatsApp connection: there is no wire for a
2425
+ // trace to show, and stopping a maintenance command to ask is what makes
2426
+ // `wa apk-material --download && wa refresh-version --all` prompt twice.
2427
+ const OFFLINE = ['version', '--version', '-v', 'help', '--help', '-h',
2428
+ 'apk-material', 'refresh-version'];
2417
2429
  if (cmd && OFFLINE.includes(cmd)) return Promise.resolve();
2418
2430
  if (!process.stdin.isTTY) return Promise.resolve();
2419
2431
  if (process.env.WA_NO_DONATE === '1') return Promise.resolve();
@@ -2448,6 +2460,12 @@ async function main() {
2448
2460
 
2449
2461
  _sessDir = flags.session || defaultSessionDir();
2450
2462
 
2463
+ // `--business` is the flag form of WA_BUSINESS, mapped onto the environment
2464
+ // before anything reads a device profile. The device config is env-driven, so
2465
+ // this is the whole of it: the profile, the token material, the version
2466
+ // lookup and the vname certificate all follow from that one variable.
2467
+ if (flags.business) process.env.WA_BUSINESS = '1';
2468
+
2451
2469
  if (!cmd) {
2452
2470
  out('whalibmob v' + VERSION + ' — type /help for commands');
2453
2471
  openShell();
@@ -2465,6 +2483,73 @@ async function main() {
2465
2483
  return;
2466
2484
  }
2467
2485
 
2486
+ // Bring a session's stored version up to date.
2487
+ //
2488
+ // A session announces the version it registered with, forever — nothing else
2489
+ // writes that field, so a number registered today is still announcing today's
2490
+ // version next year, and one day the server stops accepting it. Refreshing
2491
+ // the APK material does not reach the sessions already on disk; this does.
2492
+ // Run it after `wa apk-material --download`, or on a schedule.
2493
+ if (cmd === 'refresh-version') {
2494
+ const { refreshSessionVersion } = require('./lib/Registration');
2495
+ const one = normalizePhone(sub || '');
2496
+
2497
+ if (!one && !flags.all) {
2498
+ fail('usage: wa refresh-version <phone> (or --all for every session)');
2499
+ process.exit(1);
2500
+ }
2501
+
2502
+ let files;
2503
+ if (flags.all) {
2504
+ try {
2505
+ files = fs.readdirSync(_sessDir)
2506
+ .filter(f => /^\d+\.json$/.test(f))
2507
+ .map(f => path.join(_sessDir, f));
2508
+ } catch (_) { files = []; }
2509
+ if (!files.length) { fail('no sessions in ' + _sessDir); process.exit(1); }
2510
+ } else {
2511
+ files = [path.join(_sessDir, one + '.json')];
2512
+ }
2513
+
2514
+ if (process.env.WA_VERSION) {
2515
+ warn('WA_VERSION=' + process.env.WA_VERSION + ' is set — connecting will announce ' +
2516
+ 'that instead of what this command writes. Unset it for the refresh to take effect.');
2517
+ }
2518
+
2519
+ let changed = 0, failed = 0, kept = 0;
2520
+ const sources = new Set();
2521
+ for (const file of files) {
2522
+ try {
2523
+ const r = await refreshSessionVersion(file, flags.version ? { version: flags.version } : null);
2524
+ const who = '+' + r.phoneNumber + ' ' + r.os + (r.business ? '/business' : '');
2525
+ if (r.changed) {
2526
+ changed++;
2527
+ sources.add(r.source);
2528
+ out(who.padEnd(30) + r.before + ' → ' + r.after);
2529
+ } else if (r.keptNewer) {
2530
+ kept++;
2531
+ out(who.padEnd(30) + r.before + ' (kept — newer than the ' +
2532
+ r.candidate + ' available)');
2533
+ } else {
2534
+ out(who.padEnd(30) + r.after + ' (already current)');
2535
+ }
2536
+ } catch (e) {
2537
+ failed++;
2538
+ fail(path.basename(file) + ': ' + e.message);
2539
+ }
2540
+ }
2541
+
2542
+ out('');
2543
+ out(' ' + changed + ' session(s) updated' +
2544
+ (kept ? ', ' + kept + ' left alone (already ahead)' : '') +
2545
+ (failed ? ', ' + failed + ' failed' : ''));
2546
+ if (changed) {
2547
+ out(' read from ' + [...sources].join(', ') + '.');
2548
+ out(' reconnect for it to be announced.');
2549
+ }
2550
+ process.exit(failed ? 1 : 0);
2551
+ }
2552
+
2468
2553
  // Read the Android registration token material out of a WhatsApp APK.
2469
2554
  //
2470
2555
  // Registering as Android signs its token with the APK's own signing
@@ -2479,9 +2564,15 @@ async function main() {
2479
2564
  out(' wa apk-material --download fetch the APK from Google Play instead');
2480
2565
  process.exit(1);
2481
2566
  }
2567
+ // The Business build gets its own file: it is signed with different
2568
+ // certificates and carries a different classes.dex, so its token cannot be
2569
+ // computed from the consumer material. `wa apk-material --download` and
2570
+ // `--download --business` therefore do not overwrite each other.
2482
2571
  const outFile = flags.out ||
2483
2572
  process.env.WA_ANDROID_APK_MATERIAL ||
2484
- path.join(_sessDir, 'android-apk-material.json');
2573
+ path.join(_sessDir, flags.business
2574
+ ? 'android-apk-material-business.json'
2575
+ : 'android-apk-material.json');
2485
2576
  try {
2486
2577
  const AndroidApk = require('./lib/AndroidApk');
2487
2578
  let material;
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { WhalibmobClient, checkNumberStatus, fetchIosVersion, fetchWaVersion, assertRegistrationKeys } = require('./lib/Client');
4
4
  const { getDeviceConfig } = require('./lib/DeviceConfig');
5
- const { fetchAndroidVersion } = require('./lib/Registration');
5
+ const { fetchAndroidVersion, currentVersionFor, refreshSessionVersion } = require('./lib/Registration');
6
6
  const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts, storeToJson, storeFromJson } = require('./lib/Store');
7
7
  const { checkIfRegistered, requestSmsCode, verifyCode } = require('./lib/Registration');
8
8
  const { SignalProtocol } = require('./lib/signal/SignalProtocol');
@@ -65,6 +65,10 @@ module.exports = {
65
65
  fetchWaVersion,
66
66
  fetchIosVersion,
67
67
  fetchAndroidVersion,
68
+ // What a device profile should be announcing now, and writing it into a
69
+ // session that registered long enough ago to have gone stale.
70
+ currentVersionFor,
71
+ refreshSessionVersion,
68
72
  // Device config — reads WA_OS / WA_DEVICE / WA_DEVICE_* from process.env
69
73
  getDeviceConfig,
70
74
  // Store helpers
@@ -23,7 +23,8 @@
23
23
  const crypto = require('crypto');
24
24
  const http = require('http');
25
25
 
26
- const DEFAULT_PORT = 1119; // WhatsApp consumer; Business build listens on 1120
26
+ const DEFAULT_PORT = 1119; // WhatsApp consumer
27
+ const BUSINESS_PORT = 1120; // the Business build's own attestation server
27
28
  const REQUEST_TIMEMS = 15000;
28
29
 
29
30
  // ─── Empty (NONE) fallbacks ───────────────────────────────────────────────────
@@ -36,8 +37,12 @@ function fridaHost() {
36
37
  return process.env.WA_FRIDA_HOST || null;
37
38
  }
38
39
 
39
- function fridaPort() {
40
- return parseInt(process.env.WA_FRIDA_PORT, 10) || DEFAULT_PORT;
40
+ // The Business build's attestation server listens on its own port, because a
41
+ // device can have both apps installed and each hooks its own process. An
42
+ // explicit WA_FRIDA_PORT still wins over both.
43
+ function fridaPort(device) {
44
+ return parseInt(process.env.WA_FRIDA_PORT, 10) ||
45
+ ((device && device.business) ? BUSINESS_PORT : DEFAULT_PORT);
41
46
  }
42
47
 
43
48
  function isEnabled() {
@@ -62,7 +67,7 @@ function toHex(buf) {
62
67
  // Returns the parsed JSON object on success, or null on any failure/timeout so
63
68
  // the caller can fall back to the empty NONE attestation.
64
69
 
65
- function fridaGet(pathname, query) {
70
+ function fridaGet(pathname, query, device) {
66
71
  return new Promise((resolve) => {
67
72
  const host = fridaHost();
68
73
  if (!host) return resolve(null);
@@ -75,7 +80,7 @@ function fridaGet(pathname, query) {
75
80
  : '';
76
81
 
77
82
  const req = http.request(
78
- { host, port: fridaPort(), path: pathname + qs, method: 'GET', timeout: REQUEST_TIMEMS },
83
+ { host, port: fridaPort(device), path: pathname + qs, method: 'GET', timeout: REQUEST_TIMEMS },
79
84
  (res) => {
80
85
  const chunks = [];
81
86
  res.on('data', d => chunks.push(d));
@@ -99,9 +104,9 @@ function fridaGet(pathname, query) {
99
104
  // leaves them empty — the server accepts a gpia-only attestation.
100
105
  //
101
106
  // nonceB64 is the URL-safe base64 request nonce (see attestationFieldsAndroid).
102
- async function androidIntegrity(nonceB64) {
107
+ async function androidIntegrity(nonceB64, device) {
103
108
  if (!isEnabled()) return { ...EMPTY_ANDROID };
104
- const res = await fridaGet('/integrity', { authKey: nonceB64 });
109
+ const res = await fridaGet('/integrity', { authKey: nonceB64 }, device);
105
110
  if (!res || !res.token) return { ...EMPTY_ANDROID };
106
111
  return { gpia: res.token, gg: '', gi: '', gp: '', ge: '', ga: '' };
107
112
  }
@@ -117,18 +122,18 @@ async function androidIntegrity(nonceB64) {
117
122
  // server signs exactly those bytes.
118
123
  // The server returns { signature, certificate }: signature → the &H= body
119
124
  // suffix, certificate → the Authorization cert-chain header.
120
- async function androidAttestBody(encB64, authKeyB64) {
125
+ async function androidAttestBody(encB64, authKeyB64, device) {
121
126
  if (!isEnabled()) return { ...EMPTY_BODY };
122
127
  const encParam = Buffer.from(encB64, 'utf8').toString('base64');
123
- const res = await fridaGet('/cert', { authKey: authKeyB64, enc: encParam });
128
+ const res = await fridaGet('/cert', { authKey: authKeyB64, enc: encParam }, device);
124
129
  if (!res || !res.signature || !res.certificate) return { ...EMPTY_BODY };
125
130
  return { bodyAttestation: res.signature, authorizationHeader: res.certificate };
126
131
  }
127
132
 
128
133
  // ─── Android: device /info (apk hashes / signature / secret key) ──────────────
129
- async function androidInfo() {
134
+ async function androidInfo(device) {
130
135
  if (!isEnabled()) return null;
131
- return fridaGet('/info');
136
+ return fridaGet('/info', null, device);
132
137
  }
133
138
 
134
139
  // ─── iOS: App Attest (attestation + assertion + keyId) ────────────────────────
@@ -137,9 +142,9 @@ async function androidInfo() {
137
142
  // { attestation, assertion }; the keyId is generated per call on-device and
138
143
  // echoed by the server when available. clientDataHash is the base64 of the
139
144
  // Noise public key (the server SHA-256-hashes it internally).
140
- async function iosAppAttest(noisePubB64) {
145
+ async function iosAppAttest(noisePubB64, device) {
141
146
  if (!isEnabled()) return { ...EMPTY_IOS };
142
- const res = await fridaGet('/integrity', { authKey: noisePubB64 });
147
+ const res = await fridaGet('/integrity', { authKey: noisePubB64 }, device);
143
148
  if (!res || !res.attestation || !res.assertion) return { ...EMPTY_IOS };
144
149
  return {
145
150
  attestation: res.attestation,
@@ -171,7 +176,7 @@ async function attestationFields(device, nonceB64, opts) {
171
176
  const pushToken = orNull(opts.pushToken);
172
177
 
173
178
  if (device && device.os === 'android') {
174
- const a = await androidIntegrity(nonceB64 || '');
179
+ const a = await androidIntegrity(nonceB64 || '', device);
175
180
  return [
176
181
  'gpia', orNull(a.gpia),
177
182
  '_gg', orNull(a.gg),
@@ -197,9 +202,9 @@ async function attestationFields(device, nonceB64, opts) {
197
202
  async function attestBody(encB64, device, keys) {
198
203
  keys = keys || {};
199
204
  if (device && device.os === 'android') {
200
- return androidAttestBody(encB64, keys.noisePubB64 || '');
205
+ return androidAttestBody(encB64, keys.noisePubB64 || '', device);
201
206
  }
202
- const data = await iosAppAttest(keys.noisePubB64 || '');
207
+ const data = await iosAppAttest(keys.noisePubB64 || '', device);
203
208
  if (!data.attestation || !data.assertion || !data.keyId) return { ...EMPTY_BODY };
204
209
  return {
205
210
  bodyAttestation: '{"assertion":"' + data.assertion + '"}',
@@ -38,19 +38,32 @@ for (const k of Object.keys(ANDROID_DEVICE_PROFILES)) _ANDROID_MAP[_normalise(k)
38
38
  // WA_STATIC_TOKEN override static registration token
39
39
  // ─────────────────────────────────────────────────────────────────────────────
40
40
 
41
+ // WA_BUSINESS selects the WhatsApp Business build. It rides on top of the
42
+ // device profile rather than replacing it — the same handsets exist for both
43
+ // apps — and only changes which platform the profile announces, which package
44
+ // the token material is read from, and which store the version comes from.
45
+ function _wantsBusiness() {
46
+ const v = String(process.env.WA_BUSINESS || '').toLowerCase().trim();
47
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
48
+ }
49
+
41
50
  function getDeviceConfig() {
42
- const osType = (process.env.WA_OS || 'ios').toLowerCase().trim();
51
+ const osType = (process.env.WA_OS || 'ios').toLowerCase().trim();
52
+ const business = _wantsBusiness();
43
53
 
44
54
  if (osType === 'android') {
45
55
  const profileKey = _normalise(process.env.WA_DEVICE || 'samsungs24ultra');
46
56
  const found = _ANDROID_MAP[profileKey] || null;
47
57
  if (found) {
48
- return Object.assign({}, found);
58
+ return Object.assign({}, found, business
59
+ ? { business: true, platform: PLATFORM.ANDROID_BUSINESS }
60
+ : { business: false });
49
61
  }
50
62
 
51
63
  return {
52
64
  os: 'android',
53
- platform: PLATFORM.ANDROID,
65
+ business,
66
+ platform: business ? PLATFORM.ANDROID_BUSINESS : PLATFORM.ANDROID,
54
67
  model: process.env.WA_DEVICE_MODEL || 'Samsung Galaxy S24 Ultra',
55
68
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || 'Samsung',
56
69
  osVersion: process.env.WA_DEVICE_OS_VERSION || '14',
@@ -64,12 +77,15 @@ function getDeviceConfig() {
64
77
  const profileKey = _normalise(process.env.WA_DEVICE || 'iphone15pro');
65
78
  const found = _IOS_MAP[profileKey] || null;
66
79
  if (found) {
67
- return Object.assign({}, found);
80
+ return Object.assign({}, found, business
81
+ ? { business: true, platform: PLATFORM.IOS_BUSINESS }
82
+ : { business: false });
68
83
  }
69
84
 
70
85
  return {
71
86
  os: 'ios',
72
- platform: PLATFORM.IOS,
87
+ business,
88
+ platform: business ? PLATFORM.IOS_BUSINESS : PLATFORM.IOS,
73
89
  model: process.env.WA_DEVICE_MODEL || IOS_DEVICE.model,
74
90
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || IOS_DEVICE.manufacturer,
75
91
  osVersion: process.env.WA_DEVICE_OS_VERSION || IOS_DEVICE.osVersion,