whalibmob 5.12.17 → 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 +106 -26
- package/cli.js +78 -1
- package/index.js +5 -1
- package/lib/Attestation.js +21 -16
- package/lib/DeviceConfig.js +21 -5
- package/lib/Registration.js +233 -42
- package/lib/Store.js +13 -3
- package/lib/constants.js +33 -5
- package/lib/noise.js +21 -24
- package/package.json +1 -1
- package/tools/diagnose-405.js +21 -18
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)
|
|
@@ -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,46 @@ 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
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
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. `--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 }
|
|
4557
4633
|
```
|
|
4558
4634
|
|
|
4559
4635
|
#### If every Android session is refused, whatever the version
|
|
@@ -4569,14 +4645,17 @@ Sessions written before the fix repair themselves the next time they are loaded
|
|
|
4569
4645
|
— the platform is derived from the profile's `os` rather than trusted from the
|
|
4570
4646
|
file — so nothing has to be registered again.
|
|
4571
4647
|
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
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.
|
|
4580
4659
|
|
|
4581
4660
|
### Finding out what a 405 objects to
|
|
4582
4661
|
|
|
@@ -4592,21 +4671,22 @@ node $(npm root -g)/whalibmob/tools/diagnose-405.js 5568936182750
|
|
|
4592
4671
|
```
|
|
4593
4672
|
|
|
4594
4673
|
```
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
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
|
|
4598
4677
|
```
|
|
4599
4678
|
|
|
4600
4679
|
`405` means that row was refused, `401` means the client was accepted and only
|
|
4601
|
-
the credentials failed, `ok` means the login went through
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
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.
|
|
4610
4690
|
|
|
4611
4691
|
## License
|
|
4612
4692
|
|
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.
|
|
@@ -2448,6 +2452,12 @@ async function main() {
|
|
|
2448
2452
|
|
|
2449
2453
|
_sessDir = flags.session || defaultSessionDir();
|
|
2450
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
|
+
|
|
2451
2461
|
if (!cmd) {
|
|
2452
2462
|
out('whalibmob v' + VERSION + ' — type /help for commands');
|
|
2453
2463
|
openShell();
|
|
@@ -2465,6 +2475,67 @@ async function main() {
|
|
|
2465
2475
|
return;
|
|
2466
2476
|
}
|
|
2467
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
|
+
|
|
2468
2539
|
// Read the Android registration token material out of a WhatsApp APK.
|
|
2469
2540
|
//
|
|
2470
2541
|
// Registering as Android signs its token with the APK's own signing
|
|
@@ -2479,9 +2550,15 @@ async function main() {
|
|
|
2479
2550
|
out(' wa apk-material --download fetch the APK from Google Play instead');
|
|
2480
2551
|
process.exit(1);
|
|
2481
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.
|
|
2482
2557
|
const outFile = flags.out ||
|
|
2483
2558
|
process.env.WA_ANDROID_APK_MATERIAL ||
|
|
2484
|
-
path.join(_sessDir,
|
|
2559
|
+
path.join(_sessDir, flags.business
|
|
2560
|
+
? 'android-apk-material-business.json'
|
|
2561
|
+
: 'android-apk-material.json');
|
|
2485
2562
|
try {
|
|
2486
2563
|
const AndroidApk = require('./lib/AndroidApk');
|
|
2487
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/DeviceConfig.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/Registration.js
CHANGED
|
@@ -30,10 +30,18 @@ const { dbg: _whaDbg, warn: _whaWarn } = require('./logger');
|
|
|
30
30
|
// which records them as observed on a live native Android registration. Its iOS
|
|
31
31
|
// counterpart documents that the native iOS client omits all three, so the iOS
|
|
32
32
|
// envelope stays exactly as it was.
|
|
33
|
+
// The platform token in the User-Agent. The Business builds name themselves
|
|
34
|
+
// differently — `SMBA` on Android, `SMB iOS` on iOS — and the registration
|
|
35
|
+
// server reads the platform out of exactly this string.
|
|
36
|
+
function _uaPlatformName(device) {
|
|
37
|
+
if (device.os === 'android') return device.business ? 'SMBA' : 'Android';
|
|
38
|
+
return device.business ? 'SMB iOS' : 'iOS';
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
function registrationHeaders(device, waVersion) {
|
|
34
42
|
if (device.os === 'android') {
|
|
35
43
|
return {
|
|
36
|
-
'User-Agent': `WhatsApp/${waVersion}
|
|
44
|
+
'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
|
|
37
45
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
38
46
|
'Accept': 'text/json',
|
|
39
47
|
'WaMsysRequest': '1',
|
|
@@ -42,7 +50,7 @@ function registrationHeaders(device, waVersion) {
|
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
52
|
return {
|
|
45
|
-
'User-Agent': `WhatsApp/${waVersion}
|
|
53
|
+
'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.model}`,
|
|
46
54
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
47
55
|
};
|
|
48
56
|
}
|
|
@@ -315,12 +323,17 @@ const {
|
|
|
315
323
|
REGISTRATION_ENDPOINT,
|
|
316
324
|
REGISTRATION_PUBLIC_KEY,
|
|
317
325
|
IOS_STATIC_TOKEN,
|
|
326
|
+
IOS_BUSINESS_STATIC_TOKEN,
|
|
318
327
|
IOS_VERSION_FALLBACK,
|
|
319
328
|
ANDROID_VERSION_FALLBACK,
|
|
320
329
|
IOS_USER_AGENT,
|
|
321
330
|
IOS_DEVICE,
|
|
322
331
|
SIGNAL_KEY_TYPE,
|
|
323
|
-
RELEASE_CHANNEL
|
|
332
|
+
RELEASE_CHANNEL,
|
|
333
|
+
WHATSAPP_PACKAGE,
|
|
334
|
+
WHATSAPP_BUSINESS_PACKAGE,
|
|
335
|
+
IOS_BUNDLE_ID,
|
|
336
|
+
IOS_BUSINESS_BUNDLE_ID
|
|
324
337
|
} = require('./constants');
|
|
325
338
|
|
|
326
339
|
// ---------- MD5 helpers ----------
|
|
@@ -457,14 +470,19 @@ function parsePhone(phoneNumber) {
|
|
|
457
470
|
|
|
458
471
|
// ---------- WhatsApp version fetch (iOS from iTunes, Android from Play Store) ----------
|
|
459
472
|
|
|
460
|
-
|
|
461
|
-
|
|
473
|
+
// Cached per variant: the consumer and Business builds ship on their own
|
|
474
|
+
// schedules, and announcing one's version as the other is a version the server
|
|
475
|
+
// has no record of for that platform.
|
|
476
|
+
const _cachedIosVersion = {};
|
|
477
|
+
const _cachedAndroidVersion = {};
|
|
462
478
|
|
|
463
|
-
async function fetchIosVersion() {
|
|
464
|
-
|
|
479
|
+
async function fetchIosVersion(business) {
|
|
480
|
+
const key = business ? 'business' : 'personal';
|
|
481
|
+
if (_cachedIosVersion[key]) return _cachedIosVersion[key];
|
|
482
|
+
const bundleId = business ? IOS_BUSINESS_BUNDLE_ID : IOS_BUNDLE_ID;
|
|
465
483
|
return new Promise((resolve) => {
|
|
466
484
|
const req = https.get(
|
|
467
|
-
'https://itunes.apple.com/lookup?bundleId=
|
|
485
|
+
'https://itunes.apple.com/lookup?bundleId=' + bundleId,
|
|
468
486
|
{ headers: { 'User-Agent': IOS_USER_AGENT } },
|
|
469
487
|
(res) => {
|
|
470
488
|
const chunks = [];
|
|
@@ -474,7 +492,7 @@ async function fetchIosVersion() {
|
|
|
474
492
|
const json = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
475
493
|
let ver = (json.results && json.results[0] && json.results[0].version) || IOS_VERSION_FALLBACK;
|
|
476
494
|
if (!ver.startsWith('2.')) ver = '2.' + ver;
|
|
477
|
-
_cachedIosVersion = ver;
|
|
495
|
+
_cachedIosVersion[key] = ver;
|
|
478
496
|
resolve(ver);
|
|
479
497
|
} catch (_) {
|
|
480
498
|
resolve(IOS_VERSION_FALLBACK);
|
|
@@ -490,12 +508,14 @@ async function fetchIosVersion() {
|
|
|
490
508
|
// Fetch latest WhatsApp Android version from Google Play Store.
|
|
491
509
|
// Parses the 4-part "2.x.x.x" version string embedded in the page JSON data.
|
|
492
510
|
// Falls back to ANDROID_VERSION_FALLBACK on any error.
|
|
493
|
-
async function fetchAndroidVersion() {
|
|
494
|
-
|
|
511
|
+
async function fetchAndroidVersion(business) {
|
|
512
|
+
const key = business ? 'business' : 'personal';
|
|
513
|
+
if (_cachedAndroidVersion[key]) return _cachedAndroidVersion[key];
|
|
514
|
+
const packageName = business ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE;
|
|
495
515
|
try {
|
|
496
516
|
const axios = require('axios');
|
|
497
517
|
const resp = await axios.get(
|
|
498
|
-
'https://play.google.com/store/apps/details?id=
|
|
518
|
+
'https://play.google.com/store/apps/details?id=' + packageName + '&hl=en&gl=us',
|
|
499
519
|
{
|
|
500
520
|
headers: {
|
|
501
521
|
'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',
|
|
@@ -523,14 +543,14 @@ async function fetchAndroidVersion() {
|
|
|
523
543
|
// In Play Store JSON the current stable version appears first, before beta/history entries.
|
|
524
544
|
const primary = html.match(/"(2\.\d+\.\d+\.\d+)"/);
|
|
525
545
|
if (primary) {
|
|
526
|
-
_cachedAndroidVersion = primary[1];
|
|
546
|
+
_cachedAndroidVersion[key] = primary[1];
|
|
527
547
|
return primary[1];
|
|
528
548
|
}
|
|
529
549
|
|
|
530
550
|
// Secondary: unquoted version adjacent to "WhatsApp" text (catches alternate HTML structures).
|
|
531
551
|
const secondary = html.match(/WhatsApp[^<"]{0,200}?(2\.\d+\.\d+\.\d+)/);
|
|
532
552
|
if (secondary) {
|
|
533
|
-
_cachedAndroidVersion = secondary[1];
|
|
553
|
+
_cachedAndroidVersion[key] = secondary[1];
|
|
534
554
|
return secondary[1];
|
|
535
555
|
}
|
|
536
556
|
|
|
@@ -541,7 +561,7 @@ async function fetchAndroidVersion() {
|
|
|
541
561
|
if (all && all.length) {
|
|
542
562
|
const newest = all.sort(compareVersions).pop();
|
|
543
563
|
_whaDbg('[DBG] ANDROID_VERSION scraped from an unrecognised page layout: ' + newest);
|
|
544
|
-
_cachedAndroidVersion = newest;
|
|
564
|
+
_cachedAndroidVersion[key] = newest;
|
|
545
565
|
return newest;
|
|
546
566
|
}
|
|
547
567
|
|
|
@@ -577,14 +597,87 @@ function compareVersions(a, b) {
|
|
|
577
597
|
// different build than the one the token proves — which the server sees as a
|
|
578
598
|
// token that does not belong to the client sending it. The live lookup stays as
|
|
579
599
|
// the fallback for when there is no material yet.
|
|
600
|
+
/**
|
|
601
|
+
* The version a device profile should be announcing right now, and where it
|
|
602
|
+
* came from.
|
|
603
|
+
*
|
|
604
|
+
* Deliberately ignores WA_VERSION: this answers "what is current" so a stored
|
|
605
|
+
* version can be brought up to date, and an override in the environment is the
|
|
606
|
+
* opposite of that — it is what masks the session's own value at connect.
|
|
607
|
+
*
|
|
608
|
+
* @param {object} device the profile, for os and business
|
|
609
|
+
* @returns {Promise<{version: string, source: string}>}
|
|
610
|
+
*/
|
|
611
|
+
async function currentVersionFor(device) {
|
|
612
|
+
device = device || getDeviceConfig();
|
|
613
|
+
const business = !!device.business;
|
|
614
|
+
if (device.os === 'android') {
|
|
615
|
+
const material = tryLoadAndroidMaterial(device);
|
|
616
|
+
if (material && material.apkVersion) {
|
|
617
|
+
return { version: material.apkVersion, source: 'the APK the token material came from' };
|
|
618
|
+
}
|
|
619
|
+
return { version: await fetchAndroidVersion(business), source: 'the Play Store listing' };
|
|
620
|
+
}
|
|
621
|
+
return { version: await fetchIosVersion(business), source: 'the App Store listing' };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Bring a session's stored version up to date.
|
|
626
|
+
*
|
|
627
|
+
* A session records the version it registered with and announces that forever
|
|
628
|
+
* after; nothing else ever writes the field. Months later the server stops
|
|
629
|
+
* accepting it and the connect is refused with a 405 that has nothing to do
|
|
630
|
+
* with the account. Refreshing the APK material does not help on its own — it
|
|
631
|
+
* updates the material, not the sessions already on disk. This is the part that
|
|
632
|
+
* was missing.
|
|
633
|
+
*
|
|
634
|
+
* Touches `version` and nothing else. The device profile, the keys and the
|
|
635
|
+
* registration are left exactly as they were.
|
|
636
|
+
*
|
|
637
|
+
* @param {string} sessionFile path to <number>.json
|
|
638
|
+
* @param {object} [opts] { version } to set one explicitly
|
|
639
|
+
* @returns {Promise<{phoneNumber, before, after, changed, source}>}
|
|
640
|
+
*/
|
|
641
|
+
async function refreshSessionVersion(sessionFile, opts) {
|
|
642
|
+
opts = opts || {};
|
|
643
|
+
const { loadStore, saveStore } = require('./Store');
|
|
644
|
+
|
|
645
|
+
const store = loadStore(sessionFile);
|
|
646
|
+
if (!store) throw new Error('no session at ' + sessionFile);
|
|
647
|
+
|
|
648
|
+
const device = store.device || getDeviceConfig();
|
|
649
|
+
const before = store.version;
|
|
650
|
+
|
|
651
|
+
const resolved = opts.version
|
|
652
|
+
? { version: String(opts.version), source: 'the value you passed' }
|
|
653
|
+
: await currentVersionFor(device);
|
|
654
|
+
|
|
655
|
+
const changed = resolved.version !== before;
|
|
656
|
+
if (changed) {
|
|
657
|
+
store.version = resolved.version;
|
|
658
|
+
saveStore(store, sessionFile);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
phoneNumber: store.phoneNumber,
|
|
663
|
+
before,
|
|
664
|
+
after: resolved.version,
|
|
665
|
+
changed,
|
|
666
|
+
source: resolved.source,
|
|
667
|
+
os: device.os,
|
|
668
|
+
business: !!device.business
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
580
672
|
async function fetchWaVersion(device) {
|
|
581
673
|
if (process.env.WA_VERSION) return process.env.WA_VERSION;
|
|
674
|
+
const business = !!(device && device.business);
|
|
582
675
|
if (device && device.os === 'android') {
|
|
583
|
-
const material = tryLoadAndroidMaterial();
|
|
676
|
+
const material = tryLoadAndroidMaterial(device);
|
|
584
677
|
if (material && material.apkVersion) return material.apkVersion;
|
|
585
|
-
return fetchAndroidVersion();
|
|
678
|
+
return fetchAndroidVersion(business);
|
|
586
679
|
}
|
|
587
|
-
return fetchIosVersion();
|
|
680
|
+
return fetchIosVersion(business);
|
|
588
681
|
}
|
|
589
682
|
|
|
590
683
|
// ---------- Token computation ----------
|
|
@@ -606,17 +699,24 @@ async function fetchWaVersion(device) {
|
|
|
606
699
|
|
|
607
700
|
// Where the extracted Android material is kept. Beside the sessions by default,
|
|
608
701
|
// since that is the directory the CLI already owns.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
702
|
+
// One file per package. The two builds are signed with different certificates
|
|
703
|
+
// and carry a different classes.dex, so a token computed from the consumer APK
|
|
704
|
+
// is not the token the Business endpoint expects — and the consumer file keeps
|
|
705
|
+
// its original name, so nothing that already exists has to move.
|
|
706
|
+
function androidMaterialPath(device) {
|
|
707
|
+
if (process.env.WA_ANDROID_APK_MATERIAL) return process.env.WA_ANDROID_APK_MATERIAL;
|
|
708
|
+
const name = (device && device.business)
|
|
709
|
+
? 'android-apk-material-business.json'
|
|
710
|
+
: 'android-apk-material.json';
|
|
711
|
+
return path.join(os.homedir(), '.waSession', name);
|
|
612
712
|
}
|
|
613
713
|
|
|
614
|
-
|
|
714
|
+
const _androidMaterial = {};
|
|
615
715
|
|
|
616
716
|
// The material if there is any, without insisting on it. fetchWaVersion asks
|
|
617
717
|
// this way: a missing file is the token's problem to report, not the version's.
|
|
618
|
-
function tryLoadAndroidMaterial() {
|
|
619
|
-
try { return loadAndroidMaterial(); } catch (_) { return null; }
|
|
718
|
+
function tryLoadAndroidMaterial(device) {
|
|
719
|
+
try { return loadAndroidMaterial(device); } catch (_) { return null; }
|
|
620
720
|
}
|
|
621
721
|
|
|
622
722
|
/**
|
|
@@ -643,12 +743,25 @@ function deviceForRegistration(store, opts) {
|
|
|
643
743
|
const settled = !!(store && (store.registered || store.codePending));
|
|
644
744
|
if (!settled || !stored) return env;
|
|
645
745
|
|
|
746
|
+
const say = (opts && opts.onProgress) || (m => _whaDbg('[DBG] REG ' + m));
|
|
747
|
+
|
|
646
748
|
if (stored.os !== env.os) {
|
|
647
|
-
const say = (opts && opts.onProgress) || (m => _whaDbg('[DBG] REG ' + m));
|
|
648
749
|
say('note: this session was started as ' + stored.os + ', so it stays ' + stored.os +
|
|
649
750
|
' — WA_OS=' + env.os + ' does not apply to a number that is already part-way ' +
|
|
650
751
|
'through registering. Delete the session file to start it over as ' + env.os + '.');
|
|
651
752
|
}
|
|
753
|
+
|
|
754
|
+
// The same rule for the variant: an account is created as Business or as
|
|
755
|
+
// consumer, and the token, the User-Agent and the vname certificate all have
|
|
756
|
+
// to keep saying which. Flipping it halfway leaves a registration that no
|
|
757
|
+
// build can finish.
|
|
758
|
+
if (!!stored.business !== !!env.business) {
|
|
759
|
+
say('note: this session was started as ' + (stored.business ? 'Business' : 'consumer') +
|
|
760
|
+
', so it stays that — WA_BUSINESS=' + (env.business ? '1' : '0') +
|
|
761
|
+
' does not apply to a number that is already part-way through registering. ' +
|
|
762
|
+
'Delete the session file to start it over.');
|
|
763
|
+
}
|
|
764
|
+
|
|
652
765
|
return stored;
|
|
653
766
|
}
|
|
654
767
|
|
|
@@ -672,8 +785,11 @@ async function ensureAndroidMaterial(opts, device) {
|
|
|
672
785
|
device = device || getDeviceConfig();
|
|
673
786
|
if (device.os !== 'android') return null;
|
|
674
787
|
|
|
788
|
+
const variant = device.business ? 'business' : 'personal';
|
|
789
|
+
const packageName = device.business ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE;
|
|
790
|
+
|
|
675
791
|
try {
|
|
676
|
-
return loadAndroidMaterial();
|
|
792
|
+
return loadAndroidMaterial(device);
|
|
677
793
|
} catch (err) {
|
|
678
794
|
if (err.code !== 'NO_APK_MATERIAL') throw err;
|
|
679
795
|
if (process.env.WA_NO_APK_DOWNLOAD === '1') throw err;
|
|
@@ -683,10 +799,10 @@ async function ensureAndroidMaterial(opts, device) {
|
|
|
683
799
|
const AndroidApk = require('./AndroidApk');
|
|
684
800
|
const PlayStore = require('./PlayStore');
|
|
685
801
|
|
|
686
|
-
say('no Android token material yet — fetching
|
|
802
|
+
say('no Android token material yet — fetching ' + packageName + ' from Google Play');
|
|
687
803
|
let apk;
|
|
688
804
|
try {
|
|
689
|
-
apk = await PlayStore.downloadApk({ onProgress: m => say(' ' + m) });
|
|
805
|
+
apk = await PlayStore.downloadApk({ packageName, onProgress: m => say(' ' + m) });
|
|
690
806
|
} catch (err) {
|
|
691
807
|
throw new Error('could not fetch the WhatsApp APK automatically: ' + err.message +
|
|
692
808
|
'\nRead it out of an APK you have instead:\n' +
|
|
@@ -697,27 +813,28 @@ async function ensureAndroidMaterial(opts, device) {
|
|
|
697
813
|
if (!material.apkVersion) material.apkVersion = apk.versionName;
|
|
698
814
|
if (!material.apkVersionCode) material.apkVersionCode = apk.versionCode;
|
|
699
815
|
|
|
700
|
-
const file = androidMaterialPath();
|
|
816
|
+
const file = androidMaterialPath(device);
|
|
701
817
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
702
818
|
fs.writeFileSync(file, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
|
|
703
819
|
say(' token material written to ' + file);
|
|
704
820
|
|
|
705
821
|
// Re-read it rather than using what is in hand, so the downloaded APK goes
|
|
706
822
|
// through the same signature check as one that was already on disk.
|
|
707
|
-
_androidMaterial = null;
|
|
708
|
-
return loadAndroidMaterial();
|
|
823
|
+
_androidMaterial[variant] = null;
|
|
824
|
+
return loadAndroidMaterial(device);
|
|
709
825
|
}
|
|
710
826
|
|
|
711
|
-
function loadAndroidMaterial() {
|
|
712
|
-
|
|
713
|
-
|
|
827
|
+
function loadAndroidMaterial(device) {
|
|
828
|
+
const variant = (device && device.business) ? 'business' : 'personal';
|
|
829
|
+
if (_androidMaterial[variant]) return _androidMaterial[variant];
|
|
830
|
+
const file = androidMaterialPath(device);
|
|
714
831
|
let raw;
|
|
715
832
|
try {
|
|
716
833
|
raw = fs.readFileSync(file, 'utf8');
|
|
717
834
|
} catch (_) {
|
|
718
835
|
const err = new Error('registering as Android needs the token material from a WhatsApp APK, ' +
|
|
719
836
|
'and there is none at ' + file + '. Fetch it with:\n' +
|
|
720
|
-
' wa apk-material --download\n' +
|
|
837
|
+
' wa apk-material --download' + (variant === 'business' ? ' --business' : '') + '\n' +
|
|
721
838
|
'or read it out of an APK you already have:\n' +
|
|
722
839
|
' wa apk-material <base.apk> [split.apk ...]\n' +
|
|
723
840
|
'Registering as iOS needs none of this.');
|
|
@@ -745,20 +862,23 @@ function loadAndroidMaterial() {
|
|
|
745
862
|
'it is signed by "' + signer.subject.replace(/\n/g, ', ') + '". Re-signing replaces ' +
|
|
746
863
|
'the certificate the token is built from, so every attempt would come back as ' +
|
|
747
864
|
'bad_token. Take the APK from a phone that has WhatsApp installed:\n' +
|
|
748
|
-
' pm path
|
|
865
|
+
' pm path ' + (variant === 'business' ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE) + '\n' +
|
|
749
866
|
'then re-run wa apk-material with it. Set WA_ALLOW_FOREIGN_APK=1 to send it anyway.');
|
|
750
867
|
}
|
|
751
868
|
|
|
752
|
-
_androidMaterial = material;
|
|
753
|
-
return
|
|
869
|
+
_androidMaterial[variant] = material;
|
|
870
|
+
return material;
|
|
754
871
|
}
|
|
755
872
|
|
|
756
873
|
function computeToken(waVersion, national, device) {
|
|
757
874
|
device = device || getDeviceConfig();
|
|
758
875
|
if (device.os === 'android') {
|
|
759
|
-
return AndroidApk.computeToken(loadAndroidMaterial(), national);
|
|
876
|
+
return AndroidApk.computeToken(loadAndroidMaterial(device), national);
|
|
760
877
|
}
|
|
761
|
-
|
|
878
|
+
// The Business build signs with its own constant. WA_STATIC_TOKEN still
|
|
879
|
+
// overrides either, for anyone who has a newer one than this ships with.
|
|
880
|
+
const staticToken = process.env.WA_STATIC_TOKEN ||
|
|
881
|
+
(device.business ? IOS_BUSINESS_STATIC_TOKEN : IOS_STATIC_TOKEN);
|
|
762
882
|
const versionHashHex = md5Bytes(waVersion).toString('hex');
|
|
763
883
|
return md5Hex(staticToken + versionHashHex + national);
|
|
764
884
|
}
|
|
@@ -891,6 +1011,74 @@ function toBase64Url(buf) {
|
|
|
891
1011
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
|
|
892
1012
|
}
|
|
893
1013
|
|
|
1014
|
+
// ---------- vname: the Business verified-name certificate ----------
|
|
1015
|
+
//
|
|
1016
|
+
// A Business registration carries one extra form field, `vname`, and a consumer
|
|
1017
|
+
// one carries none. It is a VerifiedNameCertificate the client signs itself:
|
|
1018
|
+
//
|
|
1019
|
+
// Details { 1 serial (uint64), 2 issuer "smb:wa", 4 verifiedName "" }
|
|
1020
|
+
// Certificate { 1 details (the encoded Details), 2 signature }
|
|
1021
|
+
//
|
|
1022
|
+
// The name is empty because there is nothing verified yet — WhatsApp issues the
|
|
1023
|
+
// real one later, after the business is reviewed. What the server checks here is
|
|
1024
|
+
// that the signature over the details is made with the identity key the same
|
|
1025
|
+
// request registers, which ties the certificate to this account and no other.
|
|
1026
|
+
//
|
|
1027
|
+
// Written by hand rather than through the protobuf models: these two messages
|
|
1028
|
+
// exist nowhere else in the library, and the encoder here is the same field/
|
|
1029
|
+
// wire-type pair used everywhere else in proto.js.
|
|
1030
|
+
|
|
1031
|
+
const VNAME_ISSUER_SMALL_BUSINESS = 'smb:wa';
|
|
1032
|
+
|
|
1033
|
+
function _pbVarint(n) {
|
|
1034
|
+
const out = [];
|
|
1035
|
+
let v = BigInt(n);
|
|
1036
|
+
while (v > 127n) { out.push(Number((v & 0x7fn) | 0x80n)); v >>= 7n; }
|
|
1037
|
+
out.push(Number(v));
|
|
1038
|
+
return Buffer.from(out);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function _pbLenField(fieldNum, buf) {
|
|
1042
|
+
return Buffer.concat([_pbVarint((fieldNum << 3) | 2), _pbVarint(buf.length), buf]);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function _pbVarintField(fieldNum, value) {
|
|
1046
|
+
return Buffer.concat([_pbVarint((fieldNum << 3) | 0), _pbVarint(value)]);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The `vname` form value for a Business registration, or null for a consumer one.
|
|
1051
|
+
*
|
|
1052
|
+
* @param {object} store the session, for the identity key that signs it
|
|
1053
|
+
* @param {object} device the active device profile
|
|
1054
|
+
*/
|
|
1055
|
+
function buildVerifiedNameCertificate(store, device) {
|
|
1056
|
+
if (!device || !device.business) return null;
|
|
1057
|
+
|
|
1058
|
+
// A positive 63-bit serial: Java takes the absolute value of a random long,
|
|
1059
|
+
// and Math.abs(Long.MIN_VALUE) is still negative there, so the top bit is
|
|
1060
|
+
// simply never set here instead.
|
|
1061
|
+
const serialBytes = crypto.randomBytes(8);
|
|
1062
|
+
serialBytes[0] &= 0x7f;
|
|
1063
|
+
const serial = serialBytes.readBigUInt64BE(0);
|
|
1064
|
+
|
|
1065
|
+
const details = Buffer.concat([
|
|
1066
|
+
_pbVarintField(1, serial),
|
|
1067
|
+
_pbLenField(2, Buffer.from(VNAME_ISSUER_SMALL_BUSINESS, 'utf8')),
|
|
1068
|
+
_pbLenField(4, Buffer.alloc(0)) // verifiedName: empty, not absent
|
|
1069
|
+
]);
|
|
1070
|
+
|
|
1071
|
+
// Signed with the identity key, over the encoded details exactly as they go
|
|
1072
|
+
// on the wire — the same XEdDSA signature the signed pre-key uses.
|
|
1073
|
+
const signature = Buffer.from(
|
|
1074
|
+
curveJs.sign(store.identityKeyPair.private, details));
|
|
1075
|
+
|
|
1076
|
+
return toBase64Url(Buffer.concat([
|
|
1077
|
+
_pbLenField(1, details),
|
|
1078
|
+
_pbLenField(2, signature)
|
|
1079
|
+
]));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
894
1082
|
async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
895
1083
|
const { cc, national } = parsePhone(store.phoneNumber);
|
|
896
1084
|
const meta = getCountryMeta(cc);
|
|
@@ -919,6 +1107,9 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
919
1107
|
'lg', meta.lg,
|
|
920
1108
|
'lc', meta.lc,
|
|
921
1109
|
'authkey', toBase64Url(stripKeyPrefix(store.noiseKeyPair.public)),
|
|
1110
|
+
// Business only; buildForm drops a null, so a consumer registration sends
|
|
1111
|
+
// the same body it always has.
|
|
1112
|
+
'vname', buildVerifiedNameCertificate(store, device),
|
|
922
1113
|
'e_regid', toBase64Url(intToBytes(store.registrationId, 4)),
|
|
923
1114
|
'e_keytype', toBase64Url(Buffer.from([SIGNAL_KEY_TYPE])),
|
|
924
1115
|
'e_ident', toBase64Url(stripKeyPrefix(store.identityKeyPair.public)),
|
|
@@ -1622,13 +1813,13 @@ async function verifyCode(store, code, opts) {
|
|
|
1622
1813
|
throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
|
|
1623
1814
|
}
|
|
1624
1815
|
|
|
1625
|
-
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
1816
|
+
module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, currentVersionFor, refreshSessionVersion, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
|
|
1626
1817
|
|
|
1627
1818
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1628
1819
|
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
1629
1820
|
|
|
1630
1821
|
// Token computation, exposed for tests. Not part of the public API.
|
|
1631
|
-
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration };
|
|
1822
|
+
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration, buildVerifiedNameCertificate, buildPayload };
|
|
1632
1823
|
|
|
1633
1824
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1634
1825
|
module.exports._verify = {
|
package/lib/Store.js
CHANGED
|
@@ -8,7 +8,7 @@ const crypto = require('crypto');
|
|
|
8
8
|
const curveJs = require('curve25519-js');
|
|
9
9
|
const { v4: uuidv4 } = require('uuid');
|
|
10
10
|
const { IOS_DEVICE, IOS_VERSION_FALLBACK, ANDROID_VERSION_FALLBACK,
|
|
11
|
-
platformForOs } = require('./constants');
|
|
11
|
+
platformForOs, isBusinessPlatform } = require('./constants');
|
|
12
12
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
13
13
|
|
|
14
14
|
// ─────────────────────────────────────────────────────────
|
|
@@ -111,10 +111,20 @@ function createNewStore(phoneNumber) {
|
|
|
111
111
|
// deliberately, so it decides, and a stale number is repaired the moment the
|
|
112
112
|
// session is read. Nothing has to be registered again over it.
|
|
113
113
|
function normaliseDevice(device) {
|
|
114
|
-
const merged
|
|
115
|
-
|
|
114
|
+
const merged = Object.assign({}, IOS_DEVICE, device || null);
|
|
115
|
+
|
|
116
|
+
// Which variant this session is. The flag is what a session written since
|
|
117
|
+
// Business support landed carries; the stored platform number is what one
|
|
118
|
+
// written by hand, or by an older build, can still say. Either is enough, so
|
|
119
|
+
// that repairing the platform below can never turn a Business session into a
|
|
120
|
+
// consumer one.
|
|
121
|
+
const business = merged.business === true || isBusinessPlatform(merged.platform);
|
|
122
|
+
merged.business = business;
|
|
123
|
+
|
|
124
|
+
const expected = platformForOs(merged.os, business);
|
|
116
125
|
if (merged.platform !== expected) {
|
|
117
126
|
_whaDbg('[DBG] DEVICE_PLATFORM_FIXED os=' + merged.os +
|
|
127
|
+
(business ? ' business' : '') +
|
|
118
128
|
' ' + merged.platform + ' → ' + expected);
|
|
119
129
|
merged.platform = expected;
|
|
120
130
|
}
|
package/lib/constants.js
CHANGED
|
@@ -70,17 +70,40 @@ const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X)
|
|
|
70
70
|
//
|
|
71
71
|
// Anything that needs a platform number takes it from here.
|
|
72
72
|
const PLATFORM = {
|
|
73
|
-
ANDROID:
|
|
74
|
-
IOS:
|
|
75
|
-
|
|
73
|
+
ANDROID: 0,
|
|
74
|
+
IOS: 1,
|
|
75
|
+
ANDROID_BUSINESS: 10,
|
|
76
|
+
IOS_BUSINESS: 12,
|
|
77
|
+
WEB: 14
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
// The platform an `os` string announces itself as. Unknown values fall back to
|
|
79
81
|
// iOS, which is what a session with no device profile has always been.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
//
|
|
83
|
+
// The Business builds are their own platforms rather than a flag on top of the
|
|
84
|
+
// consumer ones: a WhatsApp Business account is registered, tokenised and
|
|
85
|
+
// announced as ANDROID_BUSINESS or IOS_BUSINESS from the first request, and
|
|
86
|
+
// mixing the two halves produces an account that neither app can open.
|
|
87
|
+
function platformForOs(os, business) {
|
|
88
|
+
const android = String(os).toLowerCase() === 'android';
|
|
89
|
+
if (business) return android ? PLATFORM.ANDROID_BUSINESS : PLATFORM.IOS_BUSINESS;
|
|
90
|
+
return android ? PLATFORM.ANDROID : PLATFORM.IOS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Whether a stored platform number is one of the Business ones, so a session
|
|
94
|
+
// written before this existed keeps answering the same way.
|
|
95
|
+
function isBusinessPlatform(platform) {
|
|
96
|
+
return platform === PLATFORM.ANDROID_BUSINESS || platform === PLATFORM.IOS_BUSINESS;
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
// The Play Store packages and the App Store bundle ids, per variant. The
|
|
100
|
+
// Android token material is read out of whichever APK the account is being
|
|
101
|
+
// registered as, and the two builds sign with different material entirely.
|
|
102
|
+
const WHATSAPP_PACKAGE = 'com.whatsapp';
|
|
103
|
+
const WHATSAPP_BUSINESS_PACKAGE = 'com.whatsapp.w4b';
|
|
104
|
+
const IOS_BUNDLE_ID = 'net.whatsapp.WhatsApp';
|
|
105
|
+
const IOS_BUSINESS_BUNDLE_ID = 'net.whatsapp.WhatsAppSMB';
|
|
106
|
+
|
|
84
107
|
// A note on the `deviceModelType` numbers in the profiles below: nothing reads
|
|
85
108
|
// them. The proto's deviceModelType (field 16) is a string and is filled from
|
|
86
109
|
// `modelId`; the proto's deviceType (field 15) is the DeviceType enum, which is
|
|
@@ -273,6 +296,11 @@ module.exports = {
|
|
|
273
296
|
IOS_USER_AGENT,
|
|
274
297
|
PLATFORM,
|
|
275
298
|
platformForOs,
|
|
299
|
+
isBusinessPlatform,
|
|
300
|
+
WHATSAPP_PACKAGE,
|
|
301
|
+
WHATSAPP_BUSINESS_PACKAGE,
|
|
302
|
+
IOS_BUNDLE_ID,
|
|
303
|
+
IOS_BUSINESS_BUNDLE_ID,
|
|
276
304
|
IOS_DEVICE,
|
|
277
305
|
IOS_DEVICE_PROFILES,
|
|
278
306
|
ANDROID_DEVICE_PROFILES,
|
package/lib/noise.js
CHANGED
|
@@ -375,7 +375,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
375
375
|
// ClientPayload userAgent is never sent with the telltale '000'/'000' values.
|
|
376
376
|
const _phoneMeta = getCountryMeta(parsePhone(this.store.phoneNumber).cc);
|
|
377
377
|
const _device = this.store.device || {};
|
|
378
|
-
const
|
|
378
|
+
const _reconnect = (this.store.connectAttemptCount || 0) > 0;
|
|
379
379
|
const _version = process.env.WA_VERSION || this.store.version;
|
|
380
380
|
|
|
381
381
|
this._announcedVersion = _version;
|
|
@@ -384,21 +384,20 @@ class NoiseSocket extends EventEmitter {
|
|
|
384
384
|
_whaDbg('[DBG] ANNOUNCING version=' + _version +
|
|
385
385
|
(this._versionFromEnv ? ' (from WA_VERSION, session holds ' +
|
|
386
386
|
this.store.version + ')' : ' (from the session)') +
|
|
387
|
-
' platform=' + platformForOs(_device.os));
|
|
387
|
+
' platform=' + platformForOs(_device.os, _device.business));
|
|
388
388
|
|
|
389
389
|
const payload = encodeClientPayload({
|
|
390
390
|
username: BigInt(this.store.phoneNumber),
|
|
391
391
|
passive: false,
|
|
392
392
|
pushName: this.store.registered ? (this.store.name || null) : null,
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
shortConnect: true,
|
|
393
|
+
shortConnect: _reconnect,
|
|
394
|
+
// ConnectType is a closed set — 0 CELLULAR_UNKNOWN, 1 WIFI_UNKNOWN,
|
|
395
|
+
// and 100-112 for the named cellular radios. There is no 3, which is
|
|
396
|
+
// what every reconnect used to send. This is the only one of these
|
|
397
|
+
// four that was wrong rather than merely different.
|
|
399
398
|
connectType: CONNECT_TYPE_WIFI,
|
|
400
399
|
connectReason: CONNECT_REASON_USER_ACTIVATED,
|
|
401
|
-
connectAttemptCount: 0,
|
|
400
|
+
connectAttemptCount: (this.store.connectAttemptCount || 0),
|
|
402
401
|
device: 0,
|
|
403
402
|
oc: false,
|
|
404
403
|
userAgent: {
|
|
@@ -410,7 +409,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
410
409
|
// corrected cannot keep announcing BlackBerry. ANDROID is 0, so the
|
|
411
410
|
// old `|| 1` fallback would also have quietly turned every Android
|
|
412
411
|
// session into an iOS one.
|
|
413
|
-
platform: platformForOs(_device.os),
|
|
412
|
+
platform: platformForOs(_device.os, _device.business),
|
|
414
413
|
// The version the session was registered with, unless WA_VERSION says
|
|
415
414
|
// otherwise. Without that override there is no way out of a 405: the
|
|
416
415
|
// version lives in the session file, the server refuses it, and the
|
|
@@ -423,25 +422,23 @@ class NoiseSocket extends EventEmitter {
|
|
|
423
422
|
// file still holds a version the server would have accepted. Both are
|
|
424
423
|
// remembered here so the failure can say which one went out.
|
|
425
424
|
version: _version,
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
425
|
+
// The real carrier and locale, derived from the number, on both
|
|
426
|
+
// platforms. Other clients send 000/000 and en/US here, and the
|
|
427
|
+
// server accepts a session either way — this was tried against it
|
|
428
|
+
// both ways and neither is refused. 000/000 is what a handset with
|
|
429
|
+
// no SIM reports, so a number with a carrier behind it saying so is
|
|
430
|
+
// the more ordinary thing to be, which is why this library picked
|
|
431
|
+
// the real values in the first place.
|
|
432
|
+
mcc: _phoneMeta.mcc,
|
|
433
|
+
mnc: _phoneMeta.mnc,
|
|
432
434
|
osVersion: _device.osVersion,
|
|
433
435
|
manufacturer: _device.manufacturer,
|
|
434
436
|
device: _device.model,
|
|
435
|
-
|
|
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.
|
|
437
|
+
osBuildNumber: _device.osBuildNumber,
|
|
441
438
|
phoneId: this.store.fdid.toUpperCase(),
|
|
442
439
|
releaseChannel: 0,
|
|
443
|
-
localeLanguage:
|
|
444
|
-
localeCountry:
|
|
440
|
+
localeLanguage: _phoneMeta.lg,
|
|
441
|
+
localeCountry: _phoneMeta.lc,
|
|
445
442
|
// DeviceType enum (field 15): PHONE. Every profile shipped here is a
|
|
446
443
|
// phone — the numeric `deviceModelType` on the profiles is unrelated
|
|
447
444
|
// to this and is read by nothing.
|
package/package.json
CHANGED
package/tools/diagnose-405.js
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
// 401 the client was accepted and only the credentials were rejected
|
|
16
16
|
// ok the login succeeded
|
|
17
17
|
//
|
|
18
|
-
// Read it by comparing rows: the first row
|
|
19
|
-
//
|
|
18
|
+
// Read it by comparing rows: the first row is what a connect actually sends, so
|
|
19
|
+
// if it is accepted the payload is not the problem — check WA_VERSION, which the
|
|
20
|
+
// CLI reads from .env and this tool does not. If it is refused, the row that
|
|
21
|
+
// behaves differently names the field responsible.
|
|
20
22
|
|
|
21
23
|
const path = require('path');
|
|
22
24
|
const os = require('os');
|
|
@@ -57,23 +59,24 @@ const meta = getCountryMeta(parsePhone(store.phoneNumber).cc);
|
|
|
57
59
|
const device = store.device || {};
|
|
58
60
|
const isAndroid = String(device.os).toLowerCase() === 'android';
|
|
59
61
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
+
// With no overrides this is exactly what a connect puts on the wire. Each
|
|
63
|
+
// override changes one field, so a row that behaves differently from the first
|
|
64
|
+
// one names the field responsible.
|
|
62
65
|
function payloadFor(over) {
|
|
63
66
|
over = over || {};
|
|
64
67
|
const ua = {
|
|
65
68
|
platform: over.platform !== undefined ? over.platform : platformForOs(device.os),
|
|
66
69
|
version: over.version || store.version,
|
|
67
|
-
mcc: over.
|
|
68
|
-
mnc: over.
|
|
70
|
+
mcc: over.noCarrier ? '000' : meta.mcc,
|
|
71
|
+
mnc: over.noCarrier ? '000' : meta.mnc,
|
|
69
72
|
osVersion: device.osVersion,
|
|
70
73
|
manufacturer: device.manufacturer,
|
|
71
74
|
device: over.useModelId ? device.modelId : device.model,
|
|
72
|
-
osBuildNumber: over.
|
|
75
|
+
osBuildNumber: over.noBuild ? null : device.osBuildNumber,
|
|
73
76
|
phoneId: over.lowerPhoneId ? store.fdid.toLowerCase() : store.fdid.toUpperCase(),
|
|
74
77
|
releaseChannel: 0,
|
|
75
|
-
localeLanguage: over.
|
|
76
|
-
localeCountry: over.
|
|
78
|
+
localeLanguage: over.noLocale ? 'en' : meta.lg,
|
|
79
|
+
localeCountry: over.noLocale ? 'US' : meta.lc,
|
|
77
80
|
deviceType: 0,
|
|
78
81
|
deviceModelType: device.modelId
|
|
79
82
|
};
|
|
@@ -81,7 +84,7 @@ function payloadFor(over) {
|
|
|
81
84
|
username: BigInt(store.phoneNumber),
|
|
82
85
|
passive: false,
|
|
83
86
|
pushName: store.registered ? (store.name || null) : null,
|
|
84
|
-
shortConnect:
|
|
87
|
+
shortConnect: false,
|
|
85
88
|
connectType: 1,
|
|
86
89
|
connectReason: 1,
|
|
87
90
|
connectAttemptCount: 0,
|
|
@@ -113,15 +116,15 @@ function attempt(over) {
|
|
|
113
116
|
|
|
114
117
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
115
118
|
|
|
116
|
-
// One variable at a time, starting from
|
|
119
|
+
// One variable at a time, starting from what this library actually sends.
|
|
117
120
|
const ROWS = [
|
|
118
|
-
['
|
|
119
|
-
['
|
|
120
|
-
['
|
|
121
|
-
['
|
|
122
|
-
['
|
|
123
|
-
['
|
|
124
|
-
['
|
|
121
|
+
['what this library sends', {}],
|
|
122
|
+
[' without the carrier (000/000)',{ noCarrier: true }],
|
|
123
|
+
[' without the locale (en/US)', { noLocale: true }],
|
|
124
|
+
[' without osBuildNumber', { noBuild: true }],
|
|
125
|
+
[' model id as the device', { useModelId: true }],
|
|
126
|
+
[' lowercase phoneId', { lowerPhoneId: true }],
|
|
127
|
+
[' none of the above', { noCarrier: true, noLocale: true, noBuild: true }],
|
|
125
128
|
['announced as iOS', { platform: PLATFORM.IOS }]
|
|
126
129
|
];
|
|
127
130
|
|