whalibmob 5.12.16 → 5.13.1
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 +36 -10
- package/README.md +198 -18
- package/cli.js +89 -1
- package/index.js +5 -1
- package/lib/Attestation.js +21 -16
- package/lib/Client.js +20 -0
- package/lib/DeviceConfig.js +23 -6
- package/lib/Registration.js +280 -45
- package/lib/Store.js +34 -4
- package/lib/constants.js +91 -25
- package/lib/noise.js +94 -19
- package/lib/webproto.js +5 -2
- package/package.json +2 -1
- package/tools/diagnose-405.js +156 -0
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
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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)
|
|
@@ -255,6 +256,7 @@ npm install -g whalibmob
|
|
|
255
256
|
- [Custom Device Fields](#custom-device-fields)
|
|
256
257
|
- [Version & Token Overrides](#version--token-overrides)
|
|
257
258
|
- [When the server answers 405 on connect](#when-the-server-answers-405-on-connect)
|
|
259
|
+
- [Finding out what a 405 objects to](#finding-out-what-a-405-objects-to)
|
|
258
260
|
|
|
259
261
|
---
|
|
260
262
|
|
|
@@ -399,6 +401,14 @@ Use a custom session directory with `--session`:
|
|
|
399
401
|
wa connect 919634847671 --session /data/my-sessions
|
|
400
402
|
```
|
|
401
403
|
|
|
404
|
+
> [!IMPORTANT]
|
|
405
|
+
> **If this is refused with `405`, do not re-register the number.** The account
|
|
406
|
+
> is fine; the server declined the version the connect announced. Check for a
|
|
407
|
+
> `WA_VERSION` in your shell or in a `.env` file in the directory you ran the
|
|
408
|
+
> command from — it overrides the version the session registered with, and a
|
|
409
|
+
> stale one left there refuses every connect from that directory. See
|
|
410
|
+
> [When the server answers 405 on connect](#when-the-server-answers-405-on-connect).
|
|
411
|
+
|
|
402
412
|
### CLI Pairing Code
|
|
403
413
|
|
|
404
414
|
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:
|
|
@@ -1635,6 +1645,47 @@ unasked, and `wa apk-material` below does the same job by hand.
|
|
|
1635
1645
|
The rest of this section is what happens behind that one command, and how to
|
|
1636
1646
|
drive each part yourself.
|
|
1637
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
|
+
|
|
1638
1689
|
#### Why an APK is involved at all
|
|
1639
1690
|
|
|
1640
1691
|
The registration token is computed differently on each platform, and only iOS
|
|
@@ -2163,7 +2214,7 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
|
|
|
2163
2214
|
| `pair_device` | `{ refs }` | the QR path produced reference strings |
|
|
2164
2215
|
| `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
|
|
2165
2216
|
| `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. |
|
|
2217
|
+
| `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
2218
|
|
|
2168
2219
|
### Reading What the Phone Sent
|
|
2169
2220
|
|
|
@@ -4467,47 +4518,176 @@ These variables override individual fields on top of the selected profile:
|
|
|
4467
4518
|
|
|
4468
4519
|
| Variable | Description |
|
|
4469
4520
|
|---|---|
|
|
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. |
|
|
4471
|
-
| `WA_STATIC_TOKEN` | Override the static token used in registration token computation. iOS only — Android has no static token. |
|
|
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. |
|
|
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). |
|
|
4472
4524
|
|
|
4473
4525
|
### When the server answers 405 on connect
|
|
4474
4526
|
|
|
4475
4527
|
```
|
|
4476
|
-
WhatsApp auth failure 405 — client outdated
|
|
4477
|
-
|
|
4528
|
+
WhatsApp auth failure 405 — client outdated. The server refused the version
|
|
4529
|
+
this connect announced, which was 2.24.10.75. That value came from WA_VERSION
|
|
4530
|
+
in the environment — the CLI also reads it out of a .env file in the directory
|
|
4531
|
+
it runs from — while the session itself holds 2.26.29.73.
|
|
4478
4532
|
```
|
|
4479
4533
|
|
|
4480
4534
|
**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
|
|
4482
|
-
|
|
4483
|
-
|
|
4535
|
+
declining the *client*, not the account: the version being announced is not one
|
|
4536
|
+
it accepts. Registering the number again is the one move that cannot help — the
|
|
4537
|
+
same version would go out and be refused identically, at the cost of a real
|
|
4484
4538
|
phone number and a code request.
|
|
4485
4539
|
|
|
4486
|
-
|
|
4540
|
+
Connecting announces exactly one version, and there are only two places it can
|
|
4541
|
+
come from:
|
|
4542
|
+
|
|
4543
|
+
| order | where the announced version comes from |
|
|
4544
|
+
|---|---|
|
|
4545
|
+
| 1 | `WA_VERSION`, from the shell **or from a `.env` file in the directory the command runs in** |
|
|
4546
|
+
| 2 | the version stored in the session file — what the number was registered with |
|
|
4547
|
+
|
|
4548
|
+
Almost every 405 is the first line winning when nobody meant it to.
|
|
4549
|
+
|
|
4550
|
+
#### Check `WA_VERSION` before anything else
|
|
4551
|
+
|
|
4552
|
+
The CLI loads `.env` from the working directory before it does anything else, so
|
|
4553
|
+
a `WA_VERSION` left in that file is announced by *every* connect started from
|
|
4554
|
+
that directory — in place of the version the session registered with, which the
|
|
4555
|
+
server would have accepted. Nothing about the session changes, so the failure
|
|
4556
|
+
looks like a dead account and is not one.
|
|
4557
|
+
|
|
4558
|
+
```sh
|
|
4559
|
+
grep -i wa_version .env ~/.env
|
|
4560
|
+
env | grep WA_VERSION
|
|
4561
|
+
```
|
|
4562
|
+
|
|
4563
|
+
Remove or comment the line, then connect again. Since 5.12.17 you do not have to
|
|
4564
|
+
go looking: the CLI says so before it connects,
|
|
4565
|
+
|
|
4566
|
+
```
|
|
4567
|
+
warning: WA_VERSION=2.24.10.75 is pinned (shell or .env in /home/you) —
|
|
4568
|
+
connecting announces it instead of the version stored in the session.
|
|
4569
|
+
```
|
|
4570
|
+
|
|
4571
|
+
and a 405 names the version that went out and which of the two places it came
|
|
4572
|
+
from, because that decides the remedy — unset the override, or pin a newer one.
|
|
4573
|
+
|
|
4574
|
+
Pin `WA_VERSION` deliberately and temporarily, to force one specific build:
|
|
4487
4575
|
|
|
4488
4576
|
```sh
|
|
4489
4577
|
WA_VERSION=2.26.30.3 wa connect 919634847671
|
|
4490
4578
|
```
|
|
4491
4579
|
|
|
4492
|
-
|
|
4580
|
+
Leaving it in `.env` means every session on that machine announces it until the
|
|
4581
|
+
day it goes stale, wherever those sessions came from.
|
|
4493
4582
|
|
|
4494
|
-
|
|
4495
|
-
|
|
4583
|
+
#### Keeping the session's own version current
|
|
4584
|
+
|
|
4585
|
+
With no override, the session announces the version it was registered with, and
|
|
4586
|
+
each platform learns that differently:
|
|
4496
4587
|
|
|
4497
4588
|
| | how the version is found | what happens when that fails |
|
|
4498
4589
|
|---|---|---|
|
|
4499
4590
|
| 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 |
|
|
4591
|
+
| Android | read from the APK the registration token material came from | `wa apk-material --download` fetches the current one |
|
|
4592
|
+
|
|
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.
|
|
4501
4597
|
|
|
4502
|
-
|
|
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:
|
|
4598
|
+
`wa refresh-version` is the part that does:
|
|
4506
4599
|
|
|
4507
4600
|
```sh
|
|
4508
|
-
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
|
|
4509
4603
|
```
|
|
4510
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. `--all` does every session in the
|
|
4615
|
+
session directory, which is what belongs in a monthly cron for a bot that is
|
|
4616
|
+
meant to stay up:
|
|
4617
|
+
|
|
4618
|
+
```sh
|
|
4619
|
+
wa apk-material --download && wa refresh-version --all
|
|
4620
|
+
```
|
|
4621
|
+
|
|
4622
|
+
`--version 2.26.30.5` writes one you name instead of looking one up. And if
|
|
4623
|
+
`WA_VERSION` is set, the command says so — the override would mask whatever it
|
|
4624
|
+
writes.
|
|
4625
|
+
|
|
4626
|
+
From Node, the same thing:
|
|
4627
|
+
|
|
4628
|
+
```js
|
|
4629
|
+
const { refreshSessionVersion, currentVersionFor } = require('whalibmob')
|
|
4630
|
+
|
|
4631
|
+
await refreshSessionVersion('/home/you/.waSession/5568936182750.json')
|
|
4632
|
+
await currentVersionFor({ os: 'android' }) // { version, source }
|
|
4633
|
+
```
|
|
4634
|
+
|
|
4635
|
+
#### If every Android session is refused, whatever the version
|
|
4636
|
+
|
|
4637
|
+
Then it is the library, not the version — update it. Until 5.12.15 the Android
|
|
4638
|
+
device profiles announced platform `3`, which is BlackBerry, a client WhatsApp
|
|
4639
|
+
stopped building in 2017. The server validates the announced app version
|
|
4640
|
+
*against the platform it was announced with*, so a current Android build arrived
|
|
4641
|
+
looking like an impossible BlackBerry one, and nothing in the failure named the
|
|
4642
|
+
platform. iOS announced `1` and was never affected.
|
|
4643
|
+
|
|
4644
|
+
Sessions written before the fix repair themselves the next time they are loaded
|
|
4645
|
+
— the platform is derived from the profile's `os` rather than trusted from the
|
|
4646
|
+
file — so nothing has to be registered again.
|
|
4647
|
+
|
|
4648
|
+
One other field in the same payload was wrong rather than merely unusual:
|
|
4649
|
+
`connectType` sent `3` on every reconnect, and the enum has no `3` — the legal
|
|
4650
|
+
values are `0` (cellular, unknown radio), `1` (wifi) and `100`–`112` for the
|
|
4651
|
+
named cellular radios. It now sends `1`.
|
|
4652
|
+
|
|
4653
|
+
Nothing else in the payload changed. Other clients announce no carrier
|
|
4654
|
+
(`mcc`/`mnc` as `000`) and `en`/`US` regardless of the number; this library
|
|
4655
|
+
announces the carrier and locale the number actually belongs to, and a live
|
|
4656
|
+
session was tried against the server both ways — neither is refused. `000/000`
|
|
4657
|
+
is what a handset with no SIM reports, so a number with a carrier behind it
|
|
4658
|
+
saying so is the more ordinary thing to be.
|
|
4659
|
+
|
|
4660
|
+
### Finding out what a 405 objects to
|
|
4661
|
+
|
|
4662
|
+
When the version is right and the connect is still refused, stop guessing:
|
|
4663
|
+
`tools/diagnose-405.js` runs the login once per payload variation and prints
|
|
4664
|
+
what the server answered each time.
|
|
4665
|
+
|
|
4666
|
+
```sh
|
|
4667
|
+
node tools/diagnose-405.js 5568936182750
|
|
4668
|
+
|
|
4669
|
+
# installed globally:
|
|
4670
|
+
node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
|
|
4671
|
+
```
|
|
4672
|
+
|
|
4673
|
+
```
|
|
4674
|
+
what this library sends ok — LOGIN ACCEPTED
|
|
4675
|
+
without the carrier (000/000) ok — LOGIN ACCEPTED
|
|
4676
|
+
without the locale (en/US) ok — LOGIN ACCEPTED
|
|
4677
|
+
```
|
|
4678
|
+
|
|
4679
|
+
`405` means that row was refused, `401` means the client was accepted and only
|
|
4680
|
+
the credentials failed, `ok` means the login went through. The first row is what
|
|
4681
|
+
a real connect puts on the wire, and each row after it changes exactly one
|
|
4682
|
+
field, so a row that behaves differently from the first names the field the
|
|
4683
|
+
server objected to. Every row uses the session already on disk: nothing is
|
|
4684
|
+
registered, no code is requested, and the session is never written to.
|
|
4685
|
+
`--dry-run` prints the payload sizes without opening a socket.
|
|
4686
|
+
|
|
4687
|
+
**If the first row is accepted while `wa connect` is refused, the payload is not
|
|
4688
|
+
the problem.** The difference is then in what the CLI reads and this tool does
|
|
4689
|
+
not — `WA_VERSION`, from `.env`. Go back to the top of this section.
|
|
4690
|
+
|
|
4511
4691
|
## License
|
|
4512
4692
|
|
|
4513
4693
|
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
|
|
|
@@ -2347,6 +2358,7 @@ usage:
|
|
|
2347
2358
|
wa registration --check <phone>
|
|
2348
2359
|
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2349
2360
|
wa apk-material --download fetch that APK from Google Play
|
|
2361
|
+
wa refresh-version <phone> update the version a session announces
|
|
2350
2362
|
wa version
|
|
2351
2363
|
|
|
2352
2364
|
options:
|
|
@@ -2356,6 +2368,9 @@ options:
|
|
|
2356
2368
|
--pair connect by linking to an existing account (8-digit code)
|
|
2357
2369
|
--method sms | voice | wa_old | email (default: sms)
|
|
2358
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
|
|
2359
2374
|
|
|
2360
2375
|
debug:
|
|
2361
2376
|
an interactive session asks once whether to trace the protocol.
|
|
@@ -2437,6 +2452,12 @@ async function main() {
|
|
|
2437
2452
|
|
|
2438
2453
|
_sessDir = flags.session || defaultSessionDir();
|
|
2439
2454
|
|
|
2455
|
+
// `--business` is the flag form of WA_BUSINESS, mapped onto the environment
|
|
2456
|
+
// before anything reads a device profile. The device config is env-driven, so
|
|
2457
|
+
// this is the whole of it: the profile, the token material, the version
|
|
2458
|
+
// lookup and the vname certificate all follow from that one variable.
|
|
2459
|
+
if (flags.business) process.env.WA_BUSINESS = '1';
|
|
2460
|
+
|
|
2440
2461
|
if (!cmd) {
|
|
2441
2462
|
out('whalibmob v' + VERSION + ' — type /help for commands');
|
|
2442
2463
|
openShell();
|
|
@@ -2454,6 +2475,67 @@ async function main() {
|
|
|
2454
2475
|
return;
|
|
2455
2476
|
}
|
|
2456
2477
|
|
|
2478
|
+
// Bring a session's stored version up to date.
|
|
2479
|
+
//
|
|
2480
|
+
// A session announces the version it registered with, forever — nothing else
|
|
2481
|
+
// writes that field, so a number registered today is still announcing today's
|
|
2482
|
+
// version next year, and one day the server stops accepting it. Refreshing
|
|
2483
|
+
// the APK material does not reach the sessions already on disk; this does.
|
|
2484
|
+
// Run it after `wa apk-material --download`, or on a schedule.
|
|
2485
|
+
if (cmd === 'refresh-version') {
|
|
2486
|
+
const { refreshSessionVersion } = require('./lib/Registration');
|
|
2487
|
+
const one = normalizePhone(sub || '');
|
|
2488
|
+
|
|
2489
|
+
if (!one && !flags.all) {
|
|
2490
|
+
fail('usage: wa refresh-version <phone> (or --all for every session)');
|
|
2491
|
+
process.exit(1);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
let files;
|
|
2495
|
+
if (flags.all) {
|
|
2496
|
+
try {
|
|
2497
|
+
files = fs.readdirSync(_sessDir)
|
|
2498
|
+
.filter(f => /^\d+\.json$/.test(f))
|
|
2499
|
+
.map(f => path.join(_sessDir, f));
|
|
2500
|
+
} catch (_) { files = []; }
|
|
2501
|
+
if (!files.length) { fail('no sessions in ' + _sessDir); process.exit(1); }
|
|
2502
|
+
} else {
|
|
2503
|
+
files = [path.join(_sessDir, one + '.json')];
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
if (process.env.WA_VERSION) {
|
|
2507
|
+
warn('WA_VERSION=' + process.env.WA_VERSION + ' is set — connecting will announce ' +
|
|
2508
|
+
'that instead of what this command writes. Unset it for the refresh to take effect.');
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
let changed = 0, failed = 0;
|
|
2512
|
+
const sources = new Set();
|
|
2513
|
+
for (const file of files) {
|
|
2514
|
+
try {
|
|
2515
|
+
const r = await refreshSessionVersion(file, flags.version ? { version: flags.version } : null);
|
|
2516
|
+
const who = '+' + r.phoneNumber + ' ' + r.os + (r.business ? '/business' : '');
|
|
2517
|
+
if (r.changed) {
|
|
2518
|
+
changed++;
|
|
2519
|
+
sources.add(r.source);
|
|
2520
|
+
out(who.padEnd(30) + r.before + ' → ' + r.after);
|
|
2521
|
+
} else {
|
|
2522
|
+
out(who.padEnd(30) + r.after + ' (already current)');
|
|
2523
|
+
}
|
|
2524
|
+
} catch (e) {
|
|
2525
|
+
failed++;
|
|
2526
|
+
fail(path.basename(file) + ': ' + e.message);
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
out('');
|
|
2531
|
+
out(' ' + changed + ' session(s) updated' + (failed ? ', ' + failed + ' failed' : ''));
|
|
2532
|
+
if (changed) {
|
|
2533
|
+
out(' read from ' + [...sources].join(', ') + '.');
|
|
2534
|
+
out(' reconnect for it to be announced.');
|
|
2535
|
+
}
|
|
2536
|
+
process.exit(failed ? 1 : 0);
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2457
2539
|
// Read the Android registration token material out of a WhatsApp APK.
|
|
2458
2540
|
//
|
|
2459
2541
|
// Registering as Android signs its token with the APK's own signing
|
|
@@ -2468,9 +2550,15 @@ async function main() {
|
|
|
2468
2550
|
out(' wa apk-material --download fetch the APK from Google Play instead');
|
|
2469
2551
|
process.exit(1);
|
|
2470
2552
|
}
|
|
2553
|
+
// The Business build gets its own file: it is signed with different
|
|
2554
|
+
// certificates and carries a different classes.dex, so its token cannot be
|
|
2555
|
+
// computed from the consumer material. `wa apk-material --download` and
|
|
2556
|
+
// `--download --business` therefore do not overwrite each other.
|
|
2471
2557
|
const outFile = flags.out ||
|
|
2472
2558
|
process.env.WA_ANDROID_APK_MATERIAL ||
|
|
2473
|
-
path.join(_sessDir,
|
|
2559
|
+
path.join(_sessDir, flags.business
|
|
2560
|
+
? 'android-apk-material-business.json'
|
|
2561
|
+
: 'android-apk-material.json');
|
|
2474
2562
|
try {
|
|
2475
2563
|
const AndroidApk = require('./lib/AndroidApk');
|
|
2476
2564
|
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
|
package/lib/Attestation.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
40
|
-
|
|
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 + '"}',
|
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;
|