whalibmob 5.12.16 → 5.12.17

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/README.md CHANGED
@@ -255,6 +255,7 @@ npm install -g whalibmob
255
255
  - [Custom Device Fields](#custom-device-fields)
256
256
  - [Version & Token Overrides](#version--token-overrides)
257
257
  - [When the server answers 405 on connect](#when-the-server-answers-405-on-connect)
258
+ - [Finding out what a 405 objects to](#finding-out-what-a-405-objects-to)
258
259
 
259
260
  ---
260
261
 
@@ -399,6 +400,14 @@ Use a custom session directory with `--session`:
399
400
  wa connect 919634847671 --session /data/my-sessions
400
401
  ```
401
402
 
403
+ > [!IMPORTANT]
404
+ > **If this is refused with `405`, do not re-register the number.** The account
405
+ > is fine; the server declined the version the connect announced. Check for a
406
+ > `WA_VERSION` in your shell or in a `.env` file in the directory you ran the
407
+ > command from — it overrides the version the session registered with, and a
408
+ > stale one left there refuses every connect from that directory. See
409
+ > [When the server answers 405 on connect](#when-the-server-answers-405-on-connect).
410
+
402
411
  ### CLI Pairing Code
403
412
 
404
413
  If the number is already in use on a phone, or the verification SMS never arrives, link to the existing account instead. When it is not obvious which way you mean, `wa connect` asks:
@@ -2163,7 +2172,7 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
2163
2172
  | `pair_device` | `{ refs }` | the QR path produced reference strings |
2164
2173
  | `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
2165
2174
  | `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
2166
- | `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Distinct from `auth_failure`, and there is nothing to re-pair. |
2175
+ | `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Fires whether the refusal arrives during the handshake or once the stream is open, and the client stops retrying either way. Distinct from `auth_failure`, and there is nothing to re-pair. |
2167
2176
 
2168
2177
  ### Reading What the Phone Sent
2169
2178
 
@@ -4467,47 +4476,138 @@ These variables override individual fields on top of the selected profile:
4467
4476
 
4468
4477
  | Variable | Description |
4469
4478
  |---|---|
4470
- | `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. |
4479
+ | `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. |
4471
4480
  | `WA_STATIC_TOKEN` | Override the static token used in registration token computation. iOS only — Android has no static token. |
4472
4481
 
4473
4482
  ### When the server answers 405 on connect
4474
4483
 
4475
4484
  ```
4476
- WhatsApp auth failure 405 — client outdated — the version this session
4477
- announces is not one the server accepts.
4485
+ WhatsApp auth failure 405 — client outdated. The server refused the version
4486
+ this connect announced, which was 2.24.10.75. That value came from WA_VERSION
4487
+ in the environment — the CLI also reads it out of a .env file in the directory
4488
+ it runs from — while the session itself holds 2.26.29.73.
4478
4489
  ```
4479
4490
 
4480
4491
  **The session is fine and the number is still registered.** 405 is the server
4481
- declining the *client*, not the account: the version being announced is one it no
4482
- longer accepts. Registering the number again is the one move that cannot help —
4483
- the same version would go out and be refused identically, at the cost of a real
4492
+ declining the *client*, not the account: the version being announced is not one
4493
+ it accepts. Registering the number again is the one move that cannot help — the
4494
+ same version would go out and be refused identically, at the cost of a real
4484
4495
  phone number and a code request.
4485
4496
 
4486
- Announce a current version instead:
4497
+ Connecting announces exactly one version, and there are only two places it can
4498
+ come from:
4499
+
4500
+ | order | where the announced version comes from |
4501
+ |---|---|
4502
+ | 1 | `WA_VERSION`, from the shell **or from a `.env` file in the directory the command runs in** |
4503
+ | 2 | the version stored in the session file — what the number was registered with |
4504
+
4505
+ Almost every 405 is the first line winning when nobody meant it to.
4506
+
4507
+ #### Check `WA_VERSION` before anything else
4508
+
4509
+ The CLI loads `.env` from the working directory before it does anything else, so
4510
+ a `WA_VERSION` left in that file is announced by *every* connect started from
4511
+ that directory — in place of the version the session registered with, which the
4512
+ server would have accepted. Nothing about the session changes, so the failure
4513
+ looks like a dead account and is not one.
4514
+
4515
+ ```sh
4516
+ grep -i wa_version .env ~/.env
4517
+ env | grep WA_VERSION
4518
+ ```
4519
+
4520
+ Remove or comment the line, then connect again. Since 5.12.17 you do not have to
4521
+ go looking: the CLI says so before it connects,
4522
+
4523
+ ```
4524
+ warning: WA_VERSION=2.24.10.75 is pinned (shell or .env in /home/you) —
4525
+ connecting announces it instead of the version stored in the session.
4526
+ ```
4527
+
4528
+ and a 405 names the version that went out and which of the two places it came
4529
+ from, because that decides the remedy — unset the override, or pin a newer one.
4530
+
4531
+ Pin `WA_VERSION` deliberately and temporarily, to force one specific build:
4487
4532
 
4488
4533
  ```sh
4489
4534
  WA_VERSION=2.26.30.3 wa connect 919634847671
4490
4535
  ```
4491
4536
 
4492
- Or put `WA_VERSION` in the `.env` file, where it applies to every command.
4537
+ Leaving it in `.env` means every session on that machine announces it until the
4538
+ day it goes stale, wherever those sessions came from.
4493
4539
 
4494
- **Where the stale version comes from.** Each platform learns its version
4495
- differently:
4540
+ #### Keeping the session's own version current
4541
+
4542
+ With no override, the session announces the version it was registered with, and
4543
+ each platform learns that differently:
4496
4544
 
4497
4545
  | | how the version is found | what happens when that fails |
4498
4546
  |---|---|---|
4499
4547
  | iOS | looked up on the App Store | falls back to a version compiled into the library, which goes stale |
4500
- | Android | read from the APK the token material came from | `wa apk-material --download` fetches the current one |
4548
+ | Android | read from the APK the registration token material came from | `wa apk-material --download` fetches the current one |
4501
4549
 
4502
- So this is mostly an iOS story: a lookup that times out or is rate limited leaves
4503
- a months-old fallback version in the session, and nothing says so until the
4504
- handshake is refused. On Android the version travels with the APK, and refreshing
4505
- the material refreshes the version with it:
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:
4506
4554
 
4507
4555
  ```sh
4508
4556
  wa apk-material --download
4509
4557
  ```
4510
4558
 
4559
+ #### If every Android session is refused, whatever the version
4560
+
4561
+ Then it is the library, not the version — update it. Until 5.12.15 the Android
4562
+ device profiles announced platform `3`, which is BlackBerry, a client WhatsApp
4563
+ stopped building in 2017. The server validates the announced app version
4564
+ *against the platform it was announced with*, so a current Android build arrived
4565
+ looking like an impossible BlackBerry one, and nothing in the failure named the
4566
+ platform. iOS announced `1` and was never affected.
4567
+
4568
+ Sessions written before the fix repair themselves the next time they are loaded
4569
+ — the platform is derived from the profile's `os` rather than trusted from the
4570
+ file — so nothing has to be registered again.
4571
+
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.
4580
+
4581
+ ### Finding out what a 405 objects to
4582
+
4583
+ When the version is right and the connect is still refused, stop guessing:
4584
+ `tools/diagnose-405.js` runs the login once per payload variation and prints
4585
+ what the server answered each time.
4586
+
4587
+ ```sh
4588
+ node tools/diagnose-405.js 5568936182750
4589
+
4590
+ # installed globally:
4591
+ node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
4592
+ ```
4593
+
4594
+ ```
4595
+ reference shape, as-is ok — LOGIN ACCEPTED
4596
+ + the real carrier (mcc/mnc) ok — LOGIN ACCEPTED
4597
+ + the real locale 405 {"reason":"405"}
4598
+ ```
4599
+
4600
+ `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.
4610
+
4511
4611
  ## License
4512
4612
 
4513
4613
  MIT
package/cli.js CHANGED
@@ -957,6 +957,17 @@ async function doConnectWeb(phone, opts) {
957
957
 
958
958
  async function doConnect(phone) {
959
959
  phone = normalizePhone(phone);
960
+
961
+ // A WA_VERSION left in .env is announced by every connect from this
962
+ // directory, in place of the version the session was registered with, and a
963
+ // stale one is refused with a 405 that says nothing about where the value
964
+ // came from. Say it out loud before connecting rather than after failing.
965
+ if (process.env.WA_VERSION) {
966
+ warn('WA_VERSION=' + process.env.WA_VERSION + ' is pinned (shell or .env in ' +
967
+ process.cwd() + ') — connecting announces it instead of the version ' +
968
+ 'stored in the session. Unset it to use the session\'s own.');
969
+ }
970
+
960
971
  const client = new WhalibmobClient({ sessionDir: _sessDir });
961
972
  attachEvents(client);
962
973
 
package/lib/Client.js CHANGED
@@ -705,6 +705,26 @@ class WhalibmobClient extends EventEmitter {
705
705
  * Anything the handshake marked as a spent session stops the loop here.
706
706
  */
707
707
  _onSocketError(err) {
708
+ // The server declined the client, not the session. The next attempt
709
+ // announces exactly the same thing and is refused exactly the same way, so
710
+ // this stops here instead of retrying once a second forever. _handleFailure
711
+ // already reads a 405 this way, but it only runs once a stream is open — a
712
+ // 405 during the handshake never reached it, which is why a refused client
713
+ // reconnected in a loop.
714
+ if (err && String(err.code) === '405') {
715
+ _whaDbg('[DBG] HANDSHAKE_REJECTED code=405 — client refused, not a dead session');
716
+ this._fatal = true;
717
+ this._reconnecting = false;
718
+ this.emit('client_rejected', {
719
+ reason: '405',
720
+ location: null,
721
+ message: err.message,
722
+ node: null
723
+ });
724
+ this.emit('error', err);
725
+ return;
726
+ }
727
+
708
728
  if (err && err.loggedOut) {
709
729
  _whaDbg('[DBG] HANDSHAKE_REJECTED code=' + err.code + ' — not retrying');
710
730
  this._fatal = true;
@@ -3,7 +3,8 @@
3
3
  const {
4
4
  IOS_DEVICE,
5
5
  IOS_DEVICE_PROFILES,
6
- ANDROID_DEVICE_PROFILES
6
+ ANDROID_DEVICE_PROFILES,
7
+ PLATFORM
7
8
  } = require('./constants');
8
9
 
9
10
  // Pre-build normalised lookup tables so that user-supplied profile keys in any
@@ -49,7 +50,7 @@ function getDeviceConfig() {
49
50
 
50
51
  return {
51
52
  os: 'android',
52
- platform: 3,
53
+ platform: PLATFORM.ANDROID,
53
54
  model: process.env.WA_DEVICE_MODEL || 'Samsung Galaxy S24 Ultra',
54
55
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || 'Samsung',
55
56
  osVersion: process.env.WA_DEVICE_OS_VERSION || '14',
@@ -68,7 +69,7 @@ function getDeviceConfig() {
68
69
 
69
70
  return {
70
71
  os: 'ios',
71
- platform: 1,
72
+ platform: PLATFORM.IOS,
72
73
  model: process.env.WA_DEVICE_MODEL || IOS_DEVICE.model,
73
74
  manufacturer: process.env.WA_DEVICE_MANUFACTURER || IOS_DEVICE.manufacturer,
74
75
  osVersion: process.env.WA_DEVICE_OS_VERSION || IOS_DEVICE.osVersion,
@@ -11,7 +11,7 @@ const { v4: uuidv4 } = require('uuid');
11
11
  const { getDeviceConfig } = require('./DeviceConfig');
12
12
  const AndroidApk = require('./AndroidApk');
13
13
  const attestation = require('./Attestation');
14
- const { dbg: _whaDbg } = require('./logger');
14
+ const { dbg: _whaDbg, warn: _whaWarn } = require('./logger');
15
15
 
16
16
  // ---------- Request envelope ----------
17
17
  //
@@ -499,12 +499,25 @@ async function fetchAndroidVersion() {
499
499
  {
500
500
  headers: {
501
501
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
502
- 'Accept-Language': 'en-US,en;q=0.9'
502
+ 'Accept': 'text/html,application/xhtml+xml',
503
+ 'Accept-Language': 'en-US,en;q=0.9',
504
+ // Only what every axios build decompresses. A default that asks for
505
+ // brotli and then gets bytes back undecoded is a page that parses to
506
+ // nothing.
507
+ 'Accept-Encoding': 'gzip, deflate'
503
508
  },
509
+ responseType: 'text',
510
+ decompress: true,
504
511
  timeout: 12000
505
512
  }
506
513
  );
507
- const html = String(resp.data);
514
+ // Buffer when the response was compressed and axios handed the bytes back
515
+ // as-is. String() on a Buffer of gzip is a page no regex will ever match,
516
+ // which is how this came to fall through to a two-year-old fallback without
517
+ // saying a word.
518
+ const html = Buffer.isBuffer(resp.data)
519
+ ? resp.data.toString('utf8')
520
+ : String(resp.data);
508
521
 
509
522
  // Primary: first quoted 4-part version string matching WhatsApp's "2.x.x.x" scheme.
510
523
  // In Play Store JSON the current stable version appears first, before beta/history entries.
@@ -520,10 +533,41 @@ async function fetchAndroidVersion() {
520
533
  _cachedAndroidVersion = secondary[1];
521
534
  return secondary[1];
522
535
  }
523
- } catch (_) {}
536
+
537
+ // Last resort: the highest 4-part version anywhere on the page. Play has
538
+ // rearranged this page before, and a version read from the wrong element
539
+ // still beats announcing one from two years ago.
540
+ const all = html.match(/2\.\d+\.\d+\.\d+/g);
541
+ if (all && all.length) {
542
+ const newest = all.sort(compareVersions).pop();
543
+ _whaDbg('[DBG] ANDROID_VERSION scraped from an unrecognised page layout: ' + newest);
544
+ _cachedAndroidVersion = newest;
545
+ return newest;
546
+ }
547
+
548
+ _whaWarn('could not read the current WhatsApp version off the Play Store page — ' +
549
+ 'falling back to ' + ANDROID_VERSION_FALLBACK + '. If the server refuses the ' +
550
+ 'client with 405, set WA_VERSION to a current version.');
551
+ return ANDROID_VERSION_FALLBACK;
552
+ } catch (err) {
553
+ _whaWarn('could not reach the Play Store to read the current WhatsApp version (' +
554
+ err.message + ') — falling back to ' + ANDROID_VERSION_FALLBACK + '.');
555
+ }
524
556
  return ANDROID_VERSION_FALLBACK;
525
557
  }
526
558
 
559
+ // Numeric, part by part: '2.26.9.75' is older than '2.26.29.73', which a
560
+ // lexicographic sort gets backwards.
561
+ function compareVersions(a, b) {
562
+ const pa = String(a).split('.').map(Number);
563
+ const pb = String(b).split('.').map(Number);
564
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
565
+ const d = (pa[i] || 0) - (pb[i] || 0);
566
+ if (d) return d;
567
+ }
568
+ return 0;
569
+ }
570
+
527
571
  // Return the appropriate WhatsApp version for the active device.
528
572
  // If WA_VERSION is set in the environment, that value is always used.
529
573
  //
package/lib/Store.js CHANGED
@@ -1,13 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { warn: _whaWarn } = require('./logger');
3
+ const { warn: _whaWarn, dbg: _whaDbg } = require('./logger');
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const crypto = require('crypto');
8
8
  const curveJs = require('curve25519-js');
9
9
  const { v4: uuidv4 } = require('uuid');
10
- const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK } = require('./constants');
10
+ const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK,
11
+ platformForOs } = require('./constants');
11
12
  const { getDeviceConfig } = require('./DeviceConfig');
12
13
 
13
14
  // ─────────────────────────────────────────────────────────
@@ -101,6 +102,25 @@ function createNewStore(phoneNumber) {
101
102
  // Serialisation / deserialisation
102
103
  // ─────────────────────────────────────────────────────────
103
104
 
105
+ // The device profile as it should be announced, whatever a session file happens
106
+ // to hold.
107
+ //
108
+ // `platform` is the number that goes into the handshake, and Android sessions
109
+ // written before it was corrected carry 3 — BlackBerry — which the server
110
+ // refuses with 405 on every connect. `os` is the part that was ever chosen
111
+ // deliberately, so it decides, and a stale number is repaired the moment the
112
+ // session is read. Nothing has to be registered again over it.
113
+ function normaliseDevice(device) {
114
+ const merged = Object.assign({}, IOS_DEVICE, device || null);
115
+ const expected = platformForOs(merged.os);
116
+ if (merged.platform !== expected) {
117
+ _whaDbg('[DBG] DEVICE_PLATFORM_FIXED os=' + merged.os +
118
+ ' ' + merged.platform + ' → ' + expected);
119
+ merged.platform = expected;
120
+ }
121
+ return merged;
122
+ }
123
+
104
124
  function storeToJson(store) {
105
125
  if (!store) {
106
126
  throw new Error('storeToJson: store is undefined or null');
@@ -112,7 +132,7 @@ function storeToJson(store) {
112
132
 
113
133
  const name = store.name || 'User';
114
134
  const version = store.version || IOS_VERSION_FALLBACK;
115
- const device = store.device ? Object.assign({}, IOS_DEVICE, store.device) : IOS_DEVICE;
135
+ const device = normaliseDevice(store.device);
116
136
 
117
137
  const advIdentity = store.advIdentity
118
138
  ? store.advIdentity.toString('base64')
@@ -156,7 +176,7 @@ function storeFromJson(obj) {
156
176
 
157
177
  const name = obj.name || 'User';
158
178
  const version = obj.version || IOS_VERSION_FALLBACK;
159
- const device = obj.device ? Object.assign({}, IOS_DEVICE, obj.device) : IOS_DEVICE;
179
+ const device = normaliseDevice(obj.device);
160
180
 
161
181
  return {
162
182
  phoneNumber: obj.phoneNumber,
package/lib/constants.js CHANGED
@@ -42,17 +42,54 @@ const IOS_STATIC_TOKEN = '0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM';
42
42
  const IOS_BUSINESS_STATIC_TOKEN = 'USUDuDYDeQhY4RF2fCSp5m3F6kJ1M2J8wS7bbNA2';
43
43
 
44
44
  // WhatsApp version fallbacks — used when live fetch fails.
45
+ //
46
+ // These are announced to the server, which refuses a build it does not
47
+ // recognise, so a fallback that has gone stale is a connection that fails for a
48
+ // reason nothing in the error names. Keep them within a few releases of what
49
+ // the stores are actually serving; the live lookup is what normally decides.
45
50
  const IOS_VERSION_FALLBACK = '2.26.9.75';
46
- const ANDROID_VERSION_FALLBACK = '2.24.13.80';
51
+ const ANDROID_VERSION_FALLBACK = '2.26.29.73';
47
52
 
48
53
  // Safari UA for the iTunes lookup only.
49
54
  const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1';
50
55
 
56
+ // ─── ClientPayload.UserAgent.Platform ────────────────────────────────────────
57
+ //
58
+ // The enum the handshake announces the client with, straight off the schema:
59
+ //
60
+ // ANDROID = 0 IOS = 1 WINDOWS_PHONE = 2 BLACKBERRY = 3 ... WEB = 14
61
+ //
62
+ // The server validates the announced app version *against this platform*, so a
63
+ // wrong value here does not read as a wrong platform — it reads as an
64
+ // impossible version and comes back as <failure reason="405"> ("client
65
+ // outdated") before a single stanza is exchanged. Android sat on 3 (BlackBerry,
66
+ // a client WhatsApp stopped building in 2017) and every Android session was
67
+ // refused on connect with a version that was two weeks old, while iOS on 1 was
68
+ // correct and never saw a 405. Nothing in the failure says which field was
69
+ // wrong, which is what made it worth this many words.
70
+ //
71
+ // Anything that needs a platform number takes it from here.
72
+ const PLATFORM = {
73
+ ANDROID: 0,
74
+ IOS: 1,
75
+ WEB: 14
76
+ };
77
+
78
+ // The platform an `os` string announces itself as. Unknown values fall back to
79
+ // iOS, which is what a session with no device profile has always been.
80
+ function platformForOs(os) {
81
+ return String(os).toLowerCase() === 'android' ? PLATFORM.ANDROID : PLATFORM.IOS;
82
+ }
83
+
84
+ // A note on the `deviceModelType` numbers in the profiles below: nothing reads
85
+ // them. The proto's deviceModelType (field 16) is a string and is filled from
86
+ // `modelId`; the proto's deviceType (field 15) is the DeviceType enum, which is
87
+ // PHONE for every profile here. The numbers are left as they were found.
88
+
51
89
  // Default iOS device (iPhone 15 Pro, iOS 17.4.1).
52
- // platform 1 = iOS in WhatsApp's ClientPayload.UserAgent proto enum.
53
90
  const IOS_DEVICE = {
54
91
  os: 'ios',
55
- platform: 1,
92
+ platform: PLATFORM.IOS,
56
93
  model: 'iPhone 15 Pro',
57
94
  manufacturer: 'Apple',
58
95
  osVersion: '17.4.1',
@@ -65,37 +102,37 @@ const IOS_DEVICE = {
65
102
  // Set WA_OS=ios WA_DEVICE=<key> in .env to select one.
66
103
  const IOS_DEVICE_PROFILES = {
67
104
  'iphone15pro': {
68
- os: 'ios', platform: 1,
105
+ os: 'ios', platform: PLATFORM.IOS,
69
106
  model: 'iPhone 15 Pro', manufacturer: 'Apple',
70
107
  osVersion: '17.4.1', osBuildNumber: '21E236',
71
108
  modelId: 'iPhone16,1', deviceModelType: 2
72
109
  },
73
110
  'iphone15': {
74
- os: 'ios', platform: 1,
111
+ os: 'ios', platform: PLATFORM.IOS,
75
112
  model: 'iPhone 15', manufacturer: 'Apple',
76
113
  osVersion: '17.4.1', osBuildNumber: '21E236',
77
114
  modelId: 'iPhone15,4', deviceModelType: 2
78
115
  },
79
116
  'iphone14': {
80
- os: 'ios', platform: 1,
117
+ os: 'ios', platform: PLATFORM.IOS,
81
118
  model: 'iPhone 14', manufacturer: 'Apple',
82
119
  osVersion: '17.4.1', osBuildNumber: '21E236',
83
120
  modelId: 'iPhone14,2', deviceModelType: 2
84
121
  },
85
122
  'iphone14pro': {
86
- os: 'ios', platform: 1,
123
+ os: 'ios', platform: PLATFORM.IOS,
87
124
  model: 'iPhone 14 Pro', manufacturer: 'Apple',
88
125
  osVersion: '17.4.1', osBuildNumber: '21E236',
89
126
  modelId: 'iPhone15,2', deviceModelType: 2
90
127
  },
91
128
  'iphone13': {
92
- os: 'ios', platform: 1,
129
+ os: 'ios', platform: PLATFORM.IOS,
93
130
  model: 'iPhone 13', manufacturer: 'Apple',
94
131
  osVersion: '16.7.8', osBuildNumber: '20H343',
95
132
  modelId: 'iPhone14,5', deviceModelType: 2
96
133
  },
97
134
  'iphone12': {
98
- os: 'ios', platform: 1,
135
+ os: 'ios', platform: PLATFORM.IOS,
99
136
  model: 'iPhone 12', manufacturer: 'Apple',
100
137
  osVersion: '15.8.3', osBuildNumber: '19H384',
101
138
  modelId: 'iPhone13,2', deviceModelType: 1
@@ -104,94 +141,93 @@ const IOS_DEVICE_PROFILES = {
104
141
 
105
142
  // ─── Android device profiles ───────────────────────────────────────────────
106
143
  // Set WA_OS=android WA_DEVICE=<key> in .env to select one.
107
- // platform 3 = ANDROID in WhatsApp's ClientPayload.UserAgent proto enum.
108
144
  const ANDROID_DEVICE_PROFILES = {
109
145
  'samsung-s24-ultra': {
110
- os: 'android', platform: 3,
146
+ os: 'android', platform: PLATFORM.ANDROID,
111
147
  model: 'Samsung Galaxy S24 Ultra', manufacturer: 'Samsung',
112
148
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
113
149
  modelId: 'SM-S928B', deviceModelType: 2
114
150
  },
115
151
  'samsung-s24': {
116
- os: 'android', platform: 3,
152
+ os: 'android', platform: PLATFORM.ANDROID,
117
153
  model: 'Samsung Galaxy S24', manufacturer: 'Samsung',
118
154
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
119
155
  modelId: 'SM-S921B', deviceModelType: 2
120
156
  },
121
157
  'samsung-s23': {
122
- os: 'android', platform: 3,
158
+ os: 'android', platform: PLATFORM.ANDROID,
123
159
  model: 'Samsung Galaxy S23', manufacturer: 'Samsung',
124
160
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
125
161
  modelId: 'SM-S911B', deviceModelType: 2
126
162
  },
127
163
  'samsung-s23-ultra': {
128
- os: 'android', platform: 3,
164
+ os: 'android', platform: PLATFORM.ANDROID,
129
165
  model: 'Samsung Galaxy S23 Ultra', manufacturer: 'Samsung',
130
166
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
131
167
  modelId: 'SM-S918B', deviceModelType: 2
132
168
  },
133
169
  'samsung-a55': {
134
- os: 'android', platform: 3,
170
+ os: 'android', platform: PLATFORM.ANDROID,
135
171
  model: 'Samsung Galaxy A55', manufacturer: 'Samsung',
136
172
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
137
173
  modelId: 'SM-A556B', deviceModelType: 1
138
174
  },
139
175
  'pixel8pro': {
140
- os: 'android', platform: 3,
176
+ os: 'android', platform: PLATFORM.ANDROID,
141
177
  model: 'Pixel 8 Pro', manufacturer: 'Google',
142
178
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
143
179
  modelId: 'Pixel 8 Pro', deviceModelType: 2
144
180
  },
145
181
  'pixel8': {
146
- os: 'android', platform: 3,
182
+ os: 'android', platform: PLATFORM.ANDROID,
147
183
  model: 'Pixel 8', manufacturer: 'Google',
148
184
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
149
185
  modelId: 'Pixel 8', deviceModelType: 2
150
186
  },
151
187
  'pixel7': {
152
- os: 'android', platform: 3,
188
+ os: 'android', platform: PLATFORM.ANDROID,
153
189
  model: 'Pixel 7', manufacturer: 'Google',
154
190
  osVersion: '14', osBuildNumber: 'TP1A.221005.002',
155
191
  modelId: 'Pixel 7', deviceModelType: 2
156
192
  },
157
193
  'pixel7a': {
158
- os: 'android', platform: 3,
194
+ os: 'android', platform: PLATFORM.ANDROID,
159
195
  model: 'Pixel 7a', manufacturer: 'Google',
160
196
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
161
197
  modelId: 'Pixel 7a', deviceModelType: 1
162
198
  },
163
199
  'xiaomi14': {
164
- os: 'android', platform: 3,
200
+ os: 'android', platform: PLATFORM.ANDROID,
165
201
  model: 'Xiaomi 14', manufacturer: 'Xiaomi',
166
202
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
167
203
  modelId: '23127PN0CG', deviceModelType: 2
168
204
  },
169
205
  'xiaomi13': {
170
- os: 'android', platform: 3,
206
+ os: 'android', platform: PLATFORM.ANDROID,
171
207
  model: 'Xiaomi 13', manufacturer: 'Xiaomi',
172
208
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
173
209
  modelId: '2211133G', deviceModelType: 2
174
210
  },
175
211
  'oneplus12': {
176
- os: 'android', platform: 3,
212
+ os: 'android', platform: PLATFORM.ANDROID,
177
213
  model: 'OnePlus 12', manufacturer: 'OnePlus',
178
214
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
179
215
  modelId: 'CPH2573', deviceModelType: 2
180
216
  },
181
217
  'oneplus11': {
182
- os: 'android', platform: 3,
218
+ os: 'android', platform: PLATFORM.ANDROID,
183
219
  model: 'OnePlus 11', manufacturer: 'OnePlus',
184
220
  osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
185
221
  modelId: 'CPH2449', deviceModelType: 2
186
222
  },
187
223
  'oppo-find-x7': {
188
- os: 'android', platform: 3,
224
+ os: 'android', platform: PLATFORM.ANDROID,
189
225
  model: 'OPPO Find X7', manufacturer: 'OPPO',
190
226
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
191
227
  modelId: 'CPH2599', deviceModelType: 2
192
228
  },
193
229
  'realme-gt5': {
194
- os: 'android', platform: 3,
230
+ os: 'android', platform: PLATFORM.ANDROID,
195
231
  model: 'realme GT 5 Pro', manufacturer: 'realme',
196
232
  osVersion: '14', osBuildNumber: 'UP1A.231005.007',
197
233
  modelId: 'RMX3888', deviceModelType: 2
@@ -235,6 +271,8 @@ module.exports = {
235
271
  IOS_VERSION_FALLBACK,
236
272
  ANDROID_VERSION_FALLBACK,
237
273
  IOS_USER_AGENT,
274
+ PLATFORM,
275
+ platformForOs,
238
276
  IOS_DEVICE,
239
277
  IOS_DEVICE_PROFILES,
240
278
  ANDROID_DEVICE_PROFILES,
package/lib/noise.js CHANGED
@@ -6,9 +6,11 @@ const EventEmitter = require('events');
6
6
  const curveJs = require('curve25519-js');
7
7
  const { sha256 } = require('@noble/hashes/sha256');
8
8
  const { hkdf } = require('@noble/hashes/hkdf');
9
- const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants');
9
+ const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT,
10
+ platformForOs } = require('./constants');
10
11
  const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
11
12
  decodeServerHello, encodeClientPayload } = require('./proto');
13
+ const { dbg: _whaDbg } = require('./logger');
12
14
  const { parsePhone, getCountryMeta } = require('./Registration');
13
15
 
14
16
  // ─── CertChain validator ────────
@@ -66,6 +68,16 @@ function validateServerCert(payloadBuf) {
66
68
  }
67
69
  }
68
70
 
71
+ // ─── ClientPayload enum values ───────────────────────────────────────────────
72
+ //
73
+ // ClientPayload.ConnectType, ClientPayload.ConnectReason and
74
+ // ClientPayload.UserAgent.DeviceType. Named here because a bare number in the
75
+ // payload is exactly how this file came to announce a connect type that is not
76
+ // in the enum at all.
77
+ const CONNECT_TYPE_WIFI = 1; // WIFI_UNKNOWN
78
+ const CONNECT_REASON_USER_ACTIVATED = 1;
79
+ const DEVICE_TYPE_PHONE = 0;
80
+
69
81
  // ─── Curve25519 DH helpers ───────────────────────────────────────────────────
70
82
 
71
83
  function stripKeyPrefix(pub) {
@@ -362,37 +374,79 @@ class NoiseSocket extends EventEmitter {
362
374
  // Auto-derive MCC/MNC and locale from the registered phone number so the
363
375
  // ClientPayload userAgent is never sent with the telltale '000'/'000' values.
364
376
  const _phoneMeta = getCountryMeta(parsePhone(this.store.phoneNumber).cc);
377
+ const _device = this.store.device || {};
378
+ const _isAndroid = String(_device.os).toLowerCase() === 'android';
379
+ const _version = process.env.WA_VERSION || this.store.version;
380
+
381
+ this._announcedVersion = _version;
382
+ this._versionFromEnv = !!process.env.WA_VERSION;
383
+ this._sessionVersion = this.store.version;
384
+ _whaDbg('[DBG] ANNOUNCING version=' + _version +
385
+ (this._versionFromEnv ? ' (from WA_VERSION, session holds ' +
386
+ this.store.version + ')' : ' (from the session)') +
387
+ ' platform=' + platformForOs(_device.os));
365
388
 
366
389
  const payload = encodeClientPayload({
367
390
  username: BigInt(this.store.phoneNumber),
368
391
  passive: false,
369
392
  pushName: this.store.registered ? (this.store.name || null) : null,
370
- shortConnect: (this.store.connectAttemptCount || 0) > 0,
371
- connectType: (this.store.connectAttemptCount || 0) > 0 ? 3 : 1,
372
- connectReason: 1,
373
- connectAttemptCount: (this.store.connectAttemptCount || 0),
393
+ // The next four are constants in the reference client: it announces
394
+ // shortConnect, WIFI_UNKNOWN, USER_ACTIVATED and attempt 0 on every
395
+ // connect, retries included. ConnectType is a closed set — 0
396
+ // CELLULAR_UNKNOWN, 1 WIFI_UNKNOWN, 100-112 for the named cellular
397
+ // radios — and a reconnect used to send 3, which is not in it.
398
+ shortConnect: true,
399
+ connectType: CONNECT_TYPE_WIFI,
400
+ connectReason: CONNECT_REASON_USER_ACTIVATED,
401
+ connectAttemptCount: 0,
374
402
  device: 0,
375
403
  oc: false,
376
404
  userAgent: {
377
- platform: (this.store.device && this.store.device.platform) || 1,
405
+ // Which client this says it is. The server checks the announced
406
+ // version against *this* platform, so a wrong number here comes back
407
+ // as 405 "client outdated" and never mentions the platform at all —
408
+ // see PLATFORM in constants.js. Derived from `os` rather than read
409
+ // out of the session, so a session file written before this was
410
+ // corrected cannot keep announcing BlackBerry. ANDROID is 0, so the
411
+ // old `|| 1` fallback would also have quietly turned every Android
412
+ // session into an iOS one.
413
+ platform: platformForOs(_device.os),
378
414
  // The version the session was registered with, unless WA_VERSION says
379
415
  // otherwise. Without that override there is no way out of a 405: the
380
416
  // version lives in the session file, the server refuses it, and the
381
417
  // only remaining move is to register the number again — which is the
382
418
  // one thing that should never be the answer to a client-side problem.
383
- version: process.env.WA_VERSION || this.store.version,
384
- mcc: _phoneMeta.mcc,
385
- mnc: _phoneMeta.mnc,
386
- osVersion: this.store.device.osVersion,
387
- manufacturer: this.store.device.manufacturer,
388
- device: this.store.device.model,
389
- osBuildNumber: this.store.device.osBuildNumber,
419
+ //
420
+ // The override is also a way *into* a 405, and a quiet one: the CLI
421
+ // reads .env before anything else, so a WA_VERSION left in that file
422
+ // is announced by every connect from that directory while the session
423
+ // file still holds a version the server would have accepted. Both are
424
+ // remembered here so the failure can say which one went out.
425
+ version: _version,
426
+ // Android announces no carrier and no locale of its own: the
427
+ // reference client sends 000/000 and en/US on this platform and is
428
+ // accepted, and this is the half that was being refused. iOS is
429
+ // accepted as it is and keeps sending the real values.
430
+ mcc: _isAndroid ? '000' : _phoneMeta.mcc,
431
+ mnc: _isAndroid ? '000' : _phoneMeta.mnc,
432
+ osVersion: _device.osVersion,
433
+ manufacturer: _device.manufacturer,
434
+ device: _device.model,
435
+ // Not sent on Android by the reference client, which describes the
436
+ // handset with manufacturer and model alone. iOS does send it.
437
+ osBuildNumber: _isAndroid ? null : _device.osBuildNumber,
438
+ // Uppercase on both platforms, even though the Android registration
439
+ // sends the same id lowercase — that asymmetry is the reference
440
+ // client's, not an oversight.
390
441
  phoneId: this.store.fdid.toUpperCase(),
391
442
  releaseChannel: 0,
392
- localeLanguage: _phoneMeta.lg,
393
- localeCountry: _phoneMeta.lc,
394
- deviceType: 0,
395
- deviceModelType: this.store.device.modelId
443
+ localeLanguage: _isAndroid ? 'en' : _phoneMeta.lg,
444
+ localeCountry: _isAndroid ? 'US' : _phoneMeta.lc,
445
+ // DeviceType enum (field 15): PHONE. Every profile shipped here is a
446
+ // phone — the numeric `deviceModelType` on the profiles is unrelated
447
+ // to this and is read by nothing.
448
+ deviceType: DEVICE_TYPE_PHONE,
449
+ deviceModelType: _device.modelId
396
450
  }
397
451
  });
398
452
 
@@ -476,12 +530,12 @@ class NoiseSocket extends EventEmitter {
476
530
  // and refused identically. _handleFailure already reads it this way
477
531
  // once a session is up; this is the same code arriving during the
478
532
  // handshake, and it was the one path still calling it revoked.
479
- '405': { wipe: false, text: 'client outdated — the version this ' +
480
- 'session announces is not one the server accepts. The ' +
481
- 'session itself is fine: set WA_VERSION to a current ' +
482
- 'WhatsApp version and connect again. On Android, ' +
483
- '`wa apk-material --download` picks the current one up on ' +
484
- 'its own.' },
533
+ // Filled in below, where the version that actually went out is
534
+ // known. Telling everyone to set WA_VERSION was wrong exactly when
535
+ // it mattered most: a stale WA_VERSION is itself a way to get a 405,
536
+ // and the advice then read as "fix it by doing more of the thing
537
+ // that broke it".
538
+ '405': { wipe: false, text: 'client outdated.' },
485
539
  '406': { wipe: true, text: 'banned.' },
486
540
  '409': { wipe: true, text: 'bad user agent — the client identified ' +
487
541
  'itself in a way the server rejects.' },
@@ -492,8 +546,32 @@ class NoiseSocket extends EventEmitter {
492
546
  '503': { wipe: false, text: 'service unavailable — worth retrying.' }
493
547
  };
494
548
  const info = FAILURE[reason];
549
+ let detail = info ? info.text : '';
550
+
551
+ // A refused client is a refused *version*, so name the one that went
552
+ // out and say where it came from. Which of the two it was decides the
553
+ // whole remedy, and the failure itself says neither.
554
+ // The companion path builds its own payload and never sets these, so
555
+ // it gets the short form rather than a sentence about undefined.
556
+ if (reason === '405' && !this._announcedVersion) {
557
+ detail += ' The server refused the version this connect announced.';
558
+ } else if (reason === '405') {
559
+ detail += ' The server refused the version this connect announced, ' +
560
+ 'which was ' + this._announcedVersion + '.';
561
+ detail += this._versionFromEnv
562
+ ? ' That value came from WA_VERSION in the environment — the CLI ' +
563
+ 'also reads it out of a .env file in the directory it runs from ' +
564
+ '— while the session itself holds ' + this._sessionVersion +
565
+ '. Unset WA_VERSION to announce the session\'s own version again.'
566
+ : ' It came from the session file. Announce a current one with ' +
567
+ 'WA_VERSION, or on Android re-read it from the store with ' +
568
+ '`wa apk-material --download`.';
569
+ detail += ' Nothing about the session is wrong and there is nothing ' +
570
+ 'to re-register.';
571
+ }
572
+
495
573
  const err = new Error('WhatsApp auth failure ' + reason +
496
- (info ? ' — ' + info.text : ''));
574
+ (detail ? ' — ' + detail : ''));
497
575
  err.code = reason;
498
576
  // True when the session is spent and the caller should register or
499
577
  // link again rather than retry.
package/lib/webproto.js CHANGED
@@ -137,8 +137,11 @@ function encodeDeviceProps(opts) {
137
137
  // device id, no phone id. That absence is part of what marks the connection as
138
138
  // a companion rather than a primary.
139
139
 
140
- const PLATFORM_WEB = 14;
141
- const PLATFORM_ANDROID = 0;
140
+ // From the one table that holds them, so the mobile and web halves can never
141
+ // drift apart on what a platform number means again.
142
+ const { PLATFORM } = require('./constants');
143
+ const PLATFORM_WEB = PLATFORM.WEB;
144
+ const PLATFORM_ANDROID = PLATFORM.ANDROID;
142
145
 
143
146
  function encodeAppVersion(v) {
144
147
  return Buffer.concat([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.12.16",
3
+ "version": "5.12.17",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",
@@ -11,6 +11,7 @@
11
11
  "index.js",
12
12
  "cli.js",
13
13
  "lib/**/*.js",
14
+ "tools/**/*.js",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  ".env.example"
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // Asks the server which part of the login it objects to.
5
+ //
6
+ // node tools/diagnose-405.js <phone> [--session <dir>]
7
+ //
8
+ // A 405 says "client outdated" and names nothing else, so the only way to find
9
+ // out what it actually dislikes is to vary one thing at a time and read the
10
+ // answer. Each row below is one login attempt with the session already on disk:
11
+ // nothing is registered, no code is requested, and the session is never
12
+ // written to. The verdict per row is what the server replied.
13
+ //
14
+ // 405 the client was refused — that row's combination is not accepted
15
+ // 401 the client was accepted and only the credentials were rejected
16
+ // ok the login succeeded
17
+ //
18
+ // Read it by comparing rows: the first row that stops saying 405 names the
19
+ // field that was the problem.
20
+
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ const LIB = path.join(__dirname, '..', 'lib');
25
+ const { loadStore } = require(path.join(LIB, 'Store.js'));
26
+ const { encodeClientPayload } = require(path.join(LIB, 'proto.js'));
27
+ const { NoiseSocket } = require(path.join(LIB, 'noise.js'));
28
+ const { parsePhone, getCountryMeta } = require(path.join(LIB, 'Registration.js'));
29
+ const { platformForOs, PLATFORM } = require(path.join(LIB, 'constants.js'));
30
+
31
+ // Keep the whole <failure> node, not just the reason the library keeps.
32
+ const BinaryNode = require(path.join(LIB, 'BinaryNode.js'));
33
+ const decodeNode = BinaryNode.decodeNode;
34
+ let lastNode = null;
35
+ BinaryNode.decodeNode = function () {
36
+ lastNode = decodeNode.apply(this, arguments);
37
+ return lastNode;
38
+ };
39
+
40
+ const args = process.argv.slice(2);
41
+ const phone = String(args[0] || '').replace(/\D/g, '');
42
+ const sessArg = args.indexOf('--session');
43
+ const sessDir = sessArg !== -1 ? args[sessArg + 1] : path.join(os.homedir(), '.waSession');
44
+
45
+ if (!phone) {
46
+ console.error('usage: node tools/diagnose-405.js <phone> [--session <dir>]');
47
+ process.exit(2);
48
+ }
49
+
50
+ const store = loadStore(path.join(sessDir, phone + '.json'));
51
+ if (!store) {
52
+ console.error('no session for ' + phone + ' in ' + sessDir);
53
+ process.exit(2);
54
+ }
55
+
56
+ const meta = getCountryMeta(parsePhone(store.phoneNumber).cc);
57
+ const device = store.device || {};
58
+ const isAndroid = String(device.os).toLowerCase() === 'android';
59
+
60
+ // What the reference client announces on Android, and the knobs that differ
61
+ // between it and what this library used to send.
62
+ function payloadFor(over) {
63
+ over = over || {};
64
+ const ua = {
65
+ platform: over.platform !== undefined ? over.platform : platformForOs(device.os),
66
+ version: over.version || store.version,
67
+ mcc: over.realCarrier ? meta.mcc : '000',
68
+ mnc: over.realCarrier ? meta.mnc : '000',
69
+ osVersion: device.osVersion,
70
+ manufacturer: device.manufacturer,
71
+ device: over.useModelId ? device.modelId : device.model,
72
+ osBuildNumber: over.withBuild ? device.osBuildNumber : null,
73
+ phoneId: over.lowerPhoneId ? store.fdid.toLowerCase() : store.fdid.toUpperCase(),
74
+ releaseChannel: 0,
75
+ localeLanguage: over.realLocale ? meta.lg : 'en',
76
+ localeCountry: over.realLocale ? meta.lc : 'US',
77
+ deviceType: 0,
78
+ deviceModelType: device.modelId
79
+ };
80
+ return encodeClientPayload({
81
+ username: BigInt(store.phoneNumber),
82
+ passive: false,
83
+ pushName: store.registered ? (store.name || null) : null,
84
+ shortConnect: true,
85
+ connectType: 1,
86
+ connectReason: 1,
87
+ connectAttemptCount: 0,
88
+ device: 0,
89
+ oc: false,
90
+ userAgent: ua
91
+ });
92
+ }
93
+
94
+ function attempt(over) {
95
+ return new Promise(resolve => {
96
+ lastNode = null;
97
+ const sock = new NoiseSocket(store, { buildPayload: () => payloadFor(over) });
98
+ let settled = false;
99
+ const finish = (verdict) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ try { sock.close(); } catch (_) {}
104
+ const attrs = lastNode && lastNode.description === 'failure' && lastNode.attrs;
105
+ resolve(verdict + (attrs ? ' ' + JSON.stringify(attrs) : ''));
106
+ };
107
+ const timer = setTimeout(() => finish('timeout'), 30000);
108
+ sock.on('open', () => finish('ok — LOGIN ACCEPTED'));
109
+ sock.on('error', e => finish(e.code ? String(e.code) : e.message));
110
+ sock.connect().catch(() => {});
111
+ });
112
+ }
113
+
114
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
115
+
116
+ // One variable at a time, starting from the reference client's exact shape.
117
+ const ROWS = [
118
+ ['reference client, as-is', {}],
119
+ [' + the real carrier (mcc/mnc)', { realCarrier: true }],
120
+ [' + the real locale', { realLocale: true }],
121
+ [' + osBuildNumber', { withBuild: true }],
122
+ [' + model id as the device', { useModelId: true }],
123
+ [' + lowercase phoneId', { lowerPhoneId: true }],
124
+ ['everything the old code sent', { realCarrier: true, realLocale: true, withBuild: true }],
125
+ ['announced as iOS', { platform: PLATFORM.IOS }]
126
+ ];
127
+
128
+ // --dry-run builds every row's payload and prints its size without opening a
129
+ // socket, so the tool itself can be checked without talking to the server.
130
+ if (args.includes('--dry-run')) {
131
+ console.log('session +' + store.phoneNumber + ' (dry run — nothing is sent)\n');
132
+ for (const [label, over] of ROWS) {
133
+ console.log(label.padEnd(34) + payloadFor(over).length + ' bytes');
134
+ }
135
+ process.exit(0);
136
+ }
137
+
138
+ (async () => {
139
+ console.log('session +' + store.phoneNumber);
140
+ console.log('platform ' + (isAndroid ? 'android' : 'ios') + ' (' + platformForOs(device.os) + ')');
141
+ console.log('version ' + store.version);
142
+ console.log('device ' + device.manufacturer + ' / ' + device.model + ' / ' + device.modelId);
143
+ console.log('build ' + (device.osBuildNumber || '—'));
144
+ console.log('');
145
+ for (const [label, over] of ROWS) {
146
+ process.stdout.write(label.padEnd(34));
147
+ const verdict = await attempt(over);
148
+ console.log(verdict);
149
+ await sleep(4000);
150
+ }
151
+ console.log('\nthe first row that is not 405 names what the server objected to.');
152
+ process.exit(0);
153
+ })();