whalibmob 5.19.1 → 5.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +16 -2
- package/README.md +47 -6
- package/lib/MediaService.js +14 -2
- package/lib/PlayStore.js +6 -0
- package/lib/Registration.js +30 -100
- package/lib/Store.js +7 -0
- package/lib/WebSocketStream.js +7 -0
- package/lib/WebVersion.js +7 -0
- package/lib/fcm.js +445 -0
- package/lib/noise.js +50 -18
- package/lib/socks.js +234 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -124,8 +124,10 @@
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
# ─── Proxy ───────────────────────────────────────────────────────────────────
|
|
127
|
-
# Route
|
|
128
|
-
#
|
|
127
|
+
# Route every outbound connection through a SOCKS proxy: the message socket
|
|
128
|
+
# (mobile TCP and web WSS alike), registration, the announced-version lookups,
|
|
129
|
+
# the APK material download and media uploads. Requires the `socks` package:
|
|
130
|
+
# npm install socks
|
|
129
131
|
#
|
|
130
132
|
# A bare host:port is assumed to be socks5. Add a scheme to be explicit, and
|
|
131
133
|
# credentials if the proxy needs them. Percent-encode any @ or : in a password.
|
|
@@ -142,6 +144,18 @@
|
|
|
142
144
|
# Only if `socks` lives somewhere require() cannot find it:
|
|
143
145
|
# WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
|
|
144
146
|
|
|
147
|
+
# ─── Push token ──────────────────────────────────────────────────────────────
|
|
148
|
+
# Every WhatsApp on a real phone holds a Firebase push token — it is how the
|
|
149
|
+
# server wakes the app. Registration fetches one from Google and ships it as
|
|
150
|
+
# push_token, so the request looks like the install it claims to be.
|
|
151
|
+
#
|
|
152
|
+
# On by default. Three plain HTTPS calls to Google, once per number, cached with
|
|
153
|
+
# the session afterwards. Any failure is silent: the field is dropped and
|
|
154
|
+
# registration proceeds exactly as it would without it.
|
|
155
|
+
#
|
|
156
|
+
# Set to 0 to skip it entirely.
|
|
157
|
+
# WA_FCM_PUSH=0
|
|
158
|
+
|
|
145
159
|
|
|
146
160
|
# ─── Registration funnel telemetry ───────────────────────────────────────────
|
|
147
161
|
# Registration reports the screens it passes through to WhatsApp's /client_log,
|
package/README.md
CHANGED
|
@@ -152,7 +152,8 @@ npm install -g whalibmob
|
|
|
152
152
|
- [Two Sessions on One Number](#two-sessions-on-one-number)
|
|
153
153
|
- [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under)
|
|
154
154
|
- [When Registration Is Refused for Consent](#when-registration-is-refused-for-consent)
|
|
155
|
-
- [
|
|
155
|
+
- [The Push Token](#the-push-token)
|
|
156
|
+
- [Routing Traffic Through a Proxy](#routing-traffic-through-a-proxy)
|
|
156
157
|
- [Saving & Restoring Sessions](#saving--restoring-sessions)
|
|
157
158
|
- [Signal Store Utilities](#signal-store-utilities)
|
|
158
159
|
- [makeCacheableSignalKeyStore](#makecacheablesignalkeystore)
|
|
@@ -2488,9 +2489,29 @@ If that is refused too, the number has to go through the real app once, on a pho
|
|
|
2488
2489
|
> [!NOTE]
|
|
2489
2490
|
> The `login` field in that reply is worth reading. Brazilian mobiles gained a ninth digit that WhatsApp never adopted, so `+5571976034186` is filed as `+557176034186`. whalibmob adopts the server's form automatically on a successful registration and saves the session under it — the digit difference is not itself the failure.
|
|
2490
2491
|
|
|
2491
|
-
##
|
|
2492
|
+
## The Push Token
|
|
2492
2493
|
|
|
2493
|
-
|
|
2494
|
+
Every WhatsApp on a real phone holds a Firebase push token. It is the address Google uses to wake the app when a message arrives, and no install exists without one — so a registration that ships no `push_token` describes a WhatsApp that cannot be notified.
|
|
2495
|
+
|
|
2496
|
+
Registration fetches a real one and sends it, in three plain HTTPS calls to Google:
|
|
2497
|
+
|
|
2498
|
+
| Step | Endpoint | Yields |
|
|
2499
|
+
|---|---|---|
|
|
2500
|
+
| 1 | `android.clients.google.com/checkin` | an android id and security token — the device's identity with Google |
|
|
2501
|
+
| 2 | `firebaseinstallations.googleapis.com` | a Firebase installation for WhatsApp's project |
|
|
2502
|
+
| 3 | `android.clients.google.com/c2dm/register3` | the FCM token itself |
|
|
2503
|
+
|
|
2504
|
+
No root, no Frida, no phone. This is unrelated to Play Integrity attestation — the two travel in the same request but come from different places, and one works without the other.
|
|
2505
|
+
|
|
2506
|
+
It runs once per number. The Firebase identity is cached on the session, because Google issues an android id once and expects it back; fetching a new one per registration step would mint a fresh phantom device each time.
|
|
2507
|
+
|
|
2508
|
+
**Failure is silent by design.** A blocked network, a refusal from Google, a malformed answer — all end with the field omitted and registration proceeding exactly as it did before push tokens were wired up. A push token helps; it is never a prerequisite.
|
|
2509
|
+
|
|
2510
|
+
Turn it off with `WA_FCM_PUSH=0`.
|
|
2511
|
+
|
|
2512
|
+
## Routing Traffic Through a Proxy
|
|
2513
|
+
|
|
2514
|
+
Two reasons to want this. Registration is the part of the protocol most likely to be refused from a datacenter IP, so a residential exit helps with security blocks and undeterminable number statuses. And on a network that cannot reach WhatsApp at all, nothing works without one.
|
|
2494
2515
|
|
|
2495
2516
|
Install the optional dependency and set one environment variable:
|
|
2496
2517
|
|
|
@@ -2517,10 +2538,30 @@ process.env.SOCKS_PROXY = 'socks5://user:pass@proxy.example.com:1080'
|
|
|
2517
2538
|
await requestSmsCode(phone, store)
|
|
2518
2539
|
```
|
|
2519
2540
|
|
|
2520
|
-
|
|
2521
|
-
|
|
2541
|
+
### What goes through it
|
|
2542
|
+
|
|
2543
|
+
Everything the library sends out:
|
|
2544
|
+
|
|
2545
|
+
| Connection | Destination |
|
|
2546
|
+
|---|---|
|
|
2547
|
+
| Message socket, mobile | raw TCP to WhatsApp |
|
|
2548
|
+
| Message socket, web | `wss://web.whatsapp.com/ws/chat` |
|
|
2549
|
+
| Registration | `v.whatsapp.net` |
|
|
2550
|
+
| Announced version, iOS | App Store lookup |
|
|
2551
|
+
| Announced version, Android | Play Store listing |
|
|
2552
|
+
| Announced version, web | `web.whatsapp.com/sw.js` |
|
|
2553
|
+
| APK material for registration | Play Store download |
|
|
2554
|
+
| Media upload | WhatsApp's media hosts |
|
|
2555
|
+
|
|
2556
|
+
Node's `http`/`https` modules ignore proxy environment variables entirely — a proxy has to be handed in as an agent, per call site. Only the registration POST used to do that, which produced a confusing failure: `curl -x socks5://…` reached WhatsApp while the library timed out on the same machine, falling back to a pinned version with only a debug line to show for it:
|
|
2557
|
+
|
|
2558
|
+
```
|
|
2559
|
+
[DBG] WEB_VERSION 2.3000.1035194821 (pinned fallback: sw.js fetch timed out)
|
|
2560
|
+
```
|
|
2561
|
+
|
|
2562
|
+
All eight paths now share one agent.
|
|
2522
2563
|
|
|
2523
|
-
If `socks` is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. In the rare case the package lives somewhere `require()` cannot find it, `WA_SOCKS_LIB` takes an absolute path to it.
|
|
2564
|
+
If `socks` is not installed, or a proxy is unreachable, you get a message saying so rather than a silent failure. A proxy URL that cannot be parsed leaves the connection direct rather than throwing — a version lookup with a pinned fallback should not take a process down over its proxy. In the rare case the package lives somewhere `require()` cannot find it, `WA_SOCKS_LIB` takes an absolute path to it.
|
|
2524
2565
|
|
|
2525
2566
|
## Saving & Restoring Sessions
|
|
2526
2567
|
|
package/lib/MediaService.js
CHANGED
|
@@ -195,11 +195,17 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
|
|
|
195
195
|
const hostname = typeof host === 'string' ? host : host.hostname;
|
|
196
196
|
const urlPath = `/${mediaPath}/${token}?auth=${auth}&token=${token}`;
|
|
197
197
|
|
|
198
|
+
const { proxyAgent } = require('./socks');
|
|
199
|
+
const agent = proxyAgent();
|
|
198
200
|
const options = {
|
|
199
201
|
hostname,
|
|
200
202
|
port: 443,
|
|
201
203
|
path: urlPath,
|
|
202
204
|
method: 'POST',
|
|
205
|
+
// The media hosts are as unreachable as the rest of WhatsApp on a network
|
|
206
|
+
// that needs a proxy. Uploading direct while the socket is proxied leaves
|
|
207
|
+
// a session that connects and then cannot send a photo.
|
|
208
|
+
agent: agent || undefined,
|
|
203
209
|
headers: Object.assign({
|
|
204
210
|
'Content-Type': 'application/octet-stream',
|
|
205
211
|
'Content-Length': encrypted.length,
|
|
@@ -265,9 +271,15 @@ const MAX_REDIRECTS = 5;
|
|
|
265
271
|
|
|
266
272
|
function _httpGet(url, opts, depth) {
|
|
267
273
|
return new Promise((resolve, reject) => {
|
|
268
|
-
const
|
|
274
|
+
const isTls = url.startsWith('https');
|
|
275
|
+
const lib = isTls ? https : http;
|
|
269
276
|
const web = !!(opts && opts.web);
|
|
270
|
-
|
|
277
|
+
// The agent tunnels TLS, so it belongs on https only. A plain-http media
|
|
278
|
+
// URL is rare enough that leaving it direct is better than handing an
|
|
279
|
+
// https.Agent to the http module and getting an obscure failure.
|
|
280
|
+
const { proxyAgent } = require('./socks');
|
|
281
|
+
const agent = isTls ? proxyAgent() : null;
|
|
282
|
+
lib.get(url, { agent: agent || undefined, headers: mediaHeaders(web) }, (res) => {
|
|
271
283
|
const status = res.statusCode || 0;
|
|
272
284
|
if (status === 301 || status === 302) {
|
|
273
285
|
res.resume(); // drop the redirect body
|
package/lib/PlayStore.js
CHANGED
|
@@ -124,12 +124,18 @@ function _request(url, opts) {
|
|
|
124
124
|
opts = opts || {};
|
|
125
125
|
return new Promise((resolve, reject) => {
|
|
126
126
|
const u = new URL(url);
|
|
127
|
+
// The APK material download is a Google endpoint, blocked on the same
|
|
128
|
+
// networks that block WhatsApp. Registration would otherwise fall back to
|
|
129
|
+
// stale certificate material with no way to refresh it.
|
|
130
|
+
const { proxyAgent } = require('./socks');
|
|
131
|
+
const agent = proxyAgent();
|
|
127
132
|
const req = https.request({
|
|
128
133
|
hostname: u.hostname,
|
|
129
134
|
port: u.port || 443,
|
|
130
135
|
path: u.pathname + u.search,
|
|
131
136
|
method: opts.method || 'GET',
|
|
132
137
|
headers: opts.headers || {},
|
|
138
|
+
agent: agent || undefined,
|
|
133
139
|
timeout: REQUEST_TIMEOUT
|
|
134
140
|
}, (res) => {
|
|
135
141
|
// The CDN answers a signed URL with a redirect more often than not.
|
package/lib/Registration.js
CHANGED
|
@@ -56,108 +56,23 @@ function registrationHeaders(device, waVersion) {
|
|
|
56
56
|
|
|
57
57
|
// ---------- SOCKS4 / SOCKS5 / Tor support ----------
|
|
58
58
|
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const SOCKS_SCHEMES = {
|
|
69
|
-
'socks:': 5,
|
|
70
|
-
'socks5:': 5,
|
|
71
|
-
'socks5h:': 5,
|
|
72
|
-
'socks4:': 4,
|
|
73
|
-
'socks4a:': 4
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// Resolve the library the way every other dependency is resolved.
|
|
77
|
-
//
|
|
78
|
-
// This used to be a hardcoded absolute path into one particular machine's
|
|
79
|
-
// global npm directory. It resolved there and nowhere else, so proxy support
|
|
80
|
-
// was dead for everybody who installed whalibmob normally — the require threw
|
|
81
|
-
// MODULE_NOT_FOUND the moment a proxy was configured. WA_SOCKS_LIB stays as an
|
|
82
|
-
// escape hatch for unusual layouts, but it is an override now, not the only
|
|
83
|
-
// way in.
|
|
84
|
-
function loadSocks() {
|
|
85
|
-
const override = process.env.WA_SOCKS_LIB;
|
|
86
|
-
if (override) {
|
|
87
|
-
try { return require(override); }
|
|
88
|
-
catch (err) {
|
|
89
|
-
throw new Error('WA_SOCKS_LIB points at ' + override +
|
|
90
|
-
' but it could not be loaded: ' + err.message);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
try {
|
|
94
|
-
return require('socks');
|
|
95
|
-
} catch (_) {
|
|
96
|
-
throw new Error(
|
|
97
|
-
"A SOCKS proxy is configured but the 'socks' package is not installed. " +
|
|
98
|
-
'Run: npm install socks'
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Parse a proxy URL into what SocksClient expects.
|
|
105
|
-
*
|
|
106
|
-
* Accepts the schemes above, with or without credentials, and defaults the
|
|
107
|
-
* port to 1080 the way every SOCKS client does.
|
|
108
|
-
*/
|
|
109
|
-
function parseSocksProxy(proxyUrl) {
|
|
110
|
-
let raw = String(proxyUrl || '').trim();
|
|
111
|
-
if (!raw) throw new Error('No SOCKS proxy configured');
|
|
112
|
-
|
|
113
|
-
// A bare host:port is what .env.example has always shown and what most people
|
|
114
|
-
// type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
|
|
115
|
-
// rest as the path, so it has to be filled in before parsing.
|
|
116
|
-
if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
|
|
117
|
-
|
|
118
|
-
let url;
|
|
119
|
-
try { url = new URL(raw); }
|
|
120
|
-
catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
|
|
121
|
-
|
|
122
|
-
const type = SOCKS_SCHEMES[url.protocol];
|
|
123
|
-
if (!type) {
|
|
124
|
-
throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
|
|
125
|
-
Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
|
|
126
|
-
}
|
|
127
|
-
if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
|
|
128
|
-
|
|
129
|
-
const proxy = {
|
|
130
|
-
host: url.hostname,
|
|
131
|
-
port: parseInt(url.port, 10) || 1080,
|
|
132
|
-
type
|
|
133
|
-
};
|
|
134
|
-
// Credentials may be percent-encoded in a URL; a password with an @ or a :
|
|
135
|
-
// in it is common enough on paid proxies to be worth decoding.
|
|
136
|
-
if (url.username) proxy.userId = decodeURIComponent(url.username);
|
|
137
|
-
if (url.password) proxy.password = decodeURIComponent(url.password);
|
|
138
|
-
return proxy;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// TOR_PROXY wins when both are set — the precedence .env.example has always
|
|
142
|
-
// documented.
|
|
143
|
-
function socksProxyUrl() {
|
|
144
|
-
return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
|
|
145
|
-
}
|
|
59
|
+
// The parsing, the dialer and the HTTPS agent all live in ./socks now, because
|
|
60
|
+
// registration is no longer the only thing that needs them: the version
|
|
61
|
+
// lookups below, the web sw.js probe, the WhatsApp Web socket and the mobile
|
|
62
|
+
// TCP socket route through the same proxy. They are re-exported at the bottom
|
|
63
|
+
// of this file so existing importers keep working.
|
|
64
|
+
const {
|
|
65
|
+
parseSocksProxy, socksProxyUrl, socksConnect, proxyAgent
|
|
66
|
+
} = require('./socks');
|
|
146
67
|
|
|
147
68
|
async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
|
|
148
|
-
const proxy = parseSocksProxy(proxyUrl);
|
|
149
69
|
const dHost = 'v.whatsapp.net';
|
|
150
70
|
const dPort = 443;
|
|
151
71
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
destination: { host: dHost, port: dPort },
|
|
157
|
-
// Without this a proxy that accepts the TCP connection and then says
|
|
158
|
-
// nothing leaves the whole registration hanging with no error.
|
|
159
|
-
timeout: 20000
|
|
160
|
-
});
|
|
72
|
+
// socksConnect carries the same 20s handshake timeout this used to set
|
|
73
|
+
// inline: without one, a proxy that accepts the TCP connection and then says
|
|
74
|
+
// nothing leaves the whole registration hanging with no error.
|
|
75
|
+
const rawSocket = await socksConnect(proxyUrl, dHost, dPort);
|
|
161
76
|
|
|
162
77
|
const tlsSocket = tls.connect({
|
|
163
78
|
socket: rawSocket,
|
|
@@ -671,9 +586,10 @@ async function fetchIosVersion(business) {
|
|
|
671
586
|
if (hit) return hit;
|
|
672
587
|
const bundleId = business ? IOS_BUSINESS_BUNDLE_ID : IOS_BUNDLE_ID;
|
|
673
588
|
return new Promise((resolve) => {
|
|
589
|
+
const agent = proxyAgent();
|
|
674
590
|
const req = https.get(
|
|
675
591
|
'https://itunes.apple.com/lookup?bundleId=' + bundleId,
|
|
676
|
-
{ headers: { 'User-Agent': IOS_USER_AGENT } },
|
|
592
|
+
{ agent: agent || undefined, headers: { 'User-Agent': IOS_USER_AGENT } },
|
|
677
593
|
(res) => {
|
|
678
594
|
const chunks = [];
|
|
679
595
|
res.on('data', d => chunks.push(d));
|
|
@@ -705,6 +621,10 @@ async function fetchAndroidVersion(business) {
|
|
|
705
621
|
const packageName = business ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE;
|
|
706
622
|
try {
|
|
707
623
|
const axios = require('axios');
|
|
624
|
+
// Unlike https.get, axios reads HTTP_PROXY/HTTPS_PROXY on its own. That
|
|
625
|
+
// stays untouched when no SOCKS proxy is configured; `proxy: false` is set
|
|
626
|
+
// only alongside our own agent, so the two never fight over the socket.
|
|
627
|
+
const socksAgent = proxyAgent();
|
|
708
628
|
const resp = await axios.get(
|
|
709
629
|
'https://play.google.com/store/apps/details?id=' + packageName + '&hl=en&gl=us',
|
|
710
630
|
{
|
|
@@ -719,7 +639,11 @@ async function fetchAndroidVersion(business) {
|
|
|
719
639
|
},
|
|
720
640
|
responseType: 'text',
|
|
721
641
|
decompress: true,
|
|
722
|
-
timeout: 12000
|
|
642
|
+
timeout: 12000,
|
|
643
|
+
// axios takes an agent under a different key than https.get does, but
|
|
644
|
+
// drives it identically. Omitted, this lookup ignores SOCKS_PROXY and
|
|
645
|
+
// silently falls back to a pinned version on a blocked network.
|
|
646
|
+
...(socksAgent ? { httpsAgent: socksAgent, proxy: false } : {})
|
|
723
647
|
}
|
|
724
648
|
);
|
|
725
649
|
// Buffer when the response was compressed and axios handed the bytes back
|
|
@@ -1333,7 +1257,13 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
1333
1257
|
.update(Buffer.concat([store.identityId, stripKeyPrefix(store.noiseKeyPair.public)]))
|
|
1334
1258
|
.digest()
|
|
1335
1259
|
);
|
|
1336
|
-
|
|
1260
|
+
// The push token a real install always has. Acquired from Google once and
|
|
1261
|
+
// then cached on the store, so the two registration steps share one. Returns
|
|
1262
|
+
// null on any failure, which drops the field and leaves the body exactly as
|
|
1263
|
+
// it was before push tokens were wired up.
|
|
1264
|
+
const pushToken = await require('./fcm').getPushToken(store, device);
|
|
1265
|
+
|
|
1266
|
+
const attestFields = await attestation.attestationFields(device, nonceB64, { pushToken });
|
|
1337
1267
|
|
|
1338
1268
|
return buildForm([
|
|
1339
1269
|
'cc', cc,
|
package/lib/Store.js
CHANGED
|
@@ -201,6 +201,10 @@ function storeToJson(store) {
|
|
|
201
201
|
// call it has to know, since that code is read off the caller ID rather
|
|
202
202
|
// than out of a message.
|
|
203
203
|
codeMethod: store.codeMethod || null,
|
|
204
|
+
// Firebase identity behind the push_token. Google issues an android id once
|
|
205
|
+
// and expects it back; discarding it would mint a new phantom device on
|
|
206
|
+
// every registration step, which is worse than carrying one.
|
|
207
|
+
fcm: store.fcm || null,
|
|
204
208
|
name,
|
|
205
209
|
version,
|
|
206
210
|
device,
|
|
@@ -244,6 +248,9 @@ function storeFromJson(obj) {
|
|
|
244
248
|
registered: !!obj.registered,
|
|
245
249
|
codePending: obj.codePending || false,
|
|
246
250
|
codeMethod: obj.codeMethod || null,
|
|
251
|
+
// Absent on stores written before push tokens existed; null simply means
|
|
252
|
+
// the first registration step will fetch one.
|
|
253
|
+
fcm: obj.fcm || null,
|
|
247
254
|
name,
|
|
248
255
|
version,
|
|
249
256
|
device,
|
package/lib/WebSocketStream.js
CHANGED
|
@@ -38,8 +38,15 @@ class WebSocketStream extends EventEmitter {
|
|
|
38
38
|
|
|
39
39
|
connect() {
|
|
40
40
|
const WebSocket = loadWs();
|
|
41
|
+
// ws drives an agent exactly the way https.request does, so the same SOCKS
|
|
42
|
+
// agent that carries the version probe carries the socket. Without it the
|
|
43
|
+
// upgrade request goes out directly while everything else in the process
|
|
44
|
+
// is proxied, which fails in the one situation a proxy is set for.
|
|
45
|
+
const { proxyAgent } = require('./socks');
|
|
46
|
+
const agent = proxyAgent();
|
|
41
47
|
this._ws = new WebSocket(this._url, {
|
|
42
48
|
origin: this._origin,
|
|
49
|
+
agent: agent || undefined,
|
|
43
50
|
headers: {
|
|
44
51
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
|
|
45
52
|
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
package/lib/WebVersion.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const https = require('https');
|
|
20
20
|
const { WEB_VERSION_FALLBACK } = require('./constants');
|
|
21
|
+
const { proxyAgent } = require('./socks');
|
|
21
22
|
|
|
22
23
|
const SW_URL = 'https://web.whatsapp.com/sw.js';
|
|
23
24
|
const CACHE_MS = 60 * 60 * 1000; // an hour; the revision changes far slower
|
|
@@ -27,7 +28,13 @@ let _cachedAt = 0;
|
|
|
27
28
|
|
|
28
29
|
function fetchText(url, timeoutMs) {
|
|
29
30
|
return new Promise((resolve, reject) => {
|
|
31
|
+
// Without this the request ignores SOCKS_PROXY and goes out directly —
|
|
32
|
+
// which on a network that cannot reach web.whatsapp.com means a ten-second
|
|
33
|
+
// stall and a fall back to the pinned revision, while curl through the very
|
|
34
|
+
// same proxy answers instantly.
|
|
35
|
+
const agent = proxyAgent();
|
|
30
36
|
const req = https.get(url, {
|
|
37
|
+
agent: agent || undefined,
|
|
31
38
|
headers: {
|
|
32
39
|
// The minimum that gets past the anti-bot check on this path.
|
|
33
40
|
'sec-fetch-site': 'none',
|
package/lib/fcm.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Firebase Cloud Messaging registration — the push_token a real WhatsApp
|
|
4
|
+
// install carries.
|
|
5
|
+
//
|
|
6
|
+
// Every WhatsApp on a real phone holds a push token, because that is how the
|
|
7
|
+
// server wakes it when a message arrives. A registration that ships no
|
|
8
|
+
// push_token describes a WhatsApp that cannot be notified, which is a device
|
|
9
|
+
// that does not exist. The field was already wired through
|
|
10
|
+
// Attestation.attestationFields; nothing ever filled it, so buildForm dropped
|
|
11
|
+
// it and every registration went out without one.
|
|
12
|
+
//
|
|
13
|
+
// Three steps, all plain HTTPS with Google — no root, no Frida, no phone:
|
|
14
|
+
//
|
|
15
|
+
// 1. checkin android.clients.google.com/checkin
|
|
16
|
+
// protobuf in, protobuf out; yields an android id and a
|
|
17
|
+
// security token, which together are the device's identity
|
|
18
|
+
// with Google.
|
|
19
|
+
// 2. FIS install firebaseinstallations.googleapis.com/v1/projects/…
|
|
20
|
+
// registers a Firebase installation for WhatsApp's project
|
|
21
|
+
// and returns a short-lived auth token.
|
|
22
|
+
// 3. register3 android.clients.google.com/c2dm/register3
|
|
23
|
+
// trades the two for the actual FCM token.
|
|
24
|
+
//
|
|
25
|
+
// Everything here fails soft. A blocked network, a refusal from Google, a
|
|
26
|
+
// malformed answer — all of them return null, the field is omitted, and
|
|
27
|
+
// registration proceeds exactly as it did before this file existed. A push
|
|
28
|
+
// token is a signal that helps; it is not a prerequisite, and it must never be
|
|
29
|
+
// the reason a registration cannot be attempted.
|
|
30
|
+
//
|
|
31
|
+
// The session is persisted with the account. Google hands out an android id
|
|
32
|
+
// once and expects it to keep coming back; re-running checkin on every
|
|
33
|
+
// registration step would mint a new device each time, which is itself a
|
|
34
|
+
// pattern no real phone produces.
|
|
35
|
+
|
|
36
|
+
const https = require('https');
|
|
37
|
+
const zlib = require('zlib');
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
|
|
40
|
+
const { dbg: _whaDbg } = require('./logger');
|
|
41
|
+
const { proxyAgent } = require('./socks');
|
|
42
|
+
|
|
43
|
+
// WhatsApp's own Firebase project. These are public client-side identifiers,
|
|
44
|
+
// the same ones the APK ships in google-services.json.
|
|
45
|
+
const FCM_CONFIG = {
|
|
46
|
+
personal: {
|
|
47
|
+
projectId: 'whatsapp-messenger',
|
|
48
|
+
appId: '1:293955441834:android:7373a2d0bdfa3228',
|
|
49
|
+
apiKey: 'AIzaSyCGOJbGQ95SWrXxl8wk-_cRQZcJl42bvDU',
|
|
50
|
+
senderId: '293955441834',
|
|
51
|
+
packageName: 'com.whatsapp',
|
|
52
|
+
certSha1: '38a0f7d505fe18fec64fbf343ecaaaf310dbd799'
|
|
53
|
+
},
|
|
54
|
+
business: {
|
|
55
|
+
projectId: 'whatsapp-messenger',
|
|
56
|
+
appId: '1:293955441834:android:7373a2d0bdfa3228',
|
|
57
|
+
apiKey: 'AIzaSyCGOJbGQ95SWrXxl8wk-_cRQZcJl42bvDU',
|
|
58
|
+
senderId: '293955441834',
|
|
59
|
+
packageName: 'com.whatsapp.w4b',
|
|
60
|
+
certSha1: '38a0f7d505fe18fec64fbf343ecaaaf310dbd799'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const CHECKIN_URL = 'https://android.clients.google.com/checkin';
|
|
65
|
+
const REGISTER_URL = 'https://android.clients.google.com/c2dm/register3';
|
|
66
|
+
const FIS_URL = 'https://firebaseinstallations.googleapis.com/v1/projects/%s/installations';
|
|
67
|
+
|
|
68
|
+
const HTTP_TIMEOUT_MS = 15000;
|
|
69
|
+
|
|
70
|
+
// A Firebase installation id: 17 random bytes with the top nibble pinned to
|
|
71
|
+
// 0b0111 per the FIS spec, url-safe base64, truncated to 22 characters.
|
|
72
|
+
const FID_BYTES = 17;
|
|
73
|
+
const FID_LENGTH = 22;
|
|
74
|
+
|
|
75
|
+
// ─── minimal protobuf writer ─────────────────────────────────────────────────
|
|
76
|
+
//
|
|
77
|
+
// The checkin message is the only protobuf this file has to produce, and it is
|
|
78
|
+
// a handful of scalar fields. A writer for exactly those is smaller than a
|
|
79
|
+
// schema plus a runtime, and it keeps the wire format visible next to the code
|
|
80
|
+
// that depends on it.
|
|
81
|
+
|
|
82
|
+
function varint(value) {
|
|
83
|
+
let v = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
|
|
84
|
+
if (v < 0n) v += 1n << 64n; // two's complement, as protobuf does
|
|
85
|
+
const out = [];
|
|
86
|
+
do {
|
|
87
|
+
let byte = Number(v & 0x7fn);
|
|
88
|
+
v >>= 7n;
|
|
89
|
+
if (v > 0n) byte |= 0x80;
|
|
90
|
+
out.push(byte);
|
|
91
|
+
} while (v > 0n);
|
|
92
|
+
return Buffer.from(out);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pbTag = (field, wire) => varint((field << 3) | wire);
|
|
96
|
+
const pbInt = (field, n) => Buffer.concat([pbTag(field, 0), varint(n)]);
|
|
97
|
+
const pbBool = (field, b) => Buffer.concat([pbTag(field, 0), varint(b ? 1 : 0)]);
|
|
98
|
+
|
|
99
|
+
function pbBytes(field, buf) {
|
|
100
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8');
|
|
101
|
+
return Buffer.concat([pbTag(field, 2), varint(b.length), b]);
|
|
102
|
+
}
|
|
103
|
+
const pbStr = pbBytes;
|
|
104
|
+
const pbMsg = pbBytes;
|
|
105
|
+
|
|
106
|
+
// ─── checkin request / response ──────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function encodeCheckinRequest() {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
|
|
111
|
+
// The device this claims to be. A fixed, unremarkable build: Google's own
|
|
112
|
+
// reference hardware, which is what every FCM client library has announced
|
|
113
|
+
// for a decade. Randomising it would make each registration a different
|
|
114
|
+
// phantom device, which reads worse than one ordinary repeated one.
|
|
115
|
+
const build = Buffer.concat([
|
|
116
|
+
pbStr(1, 'google/razor/flo:5.0.1/LRX22C/1602158:user/release-keys'),
|
|
117
|
+
pbStr(2, 'flo'),
|
|
118
|
+
pbStr(3, 'google'),
|
|
119
|
+
pbStr(6, 'android-google'),
|
|
120
|
+
pbInt(7, Math.floor(now / 1000)),
|
|
121
|
+
pbInt(10, 30),
|
|
122
|
+
pbStr(11, 'Nexus 7'),
|
|
123
|
+
pbStr(12, 'asus'),
|
|
124
|
+
pbStr(13, 'razor'),
|
|
125
|
+
pbBool(14, false)
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const event = Buffer.concat([
|
|
129
|
+
pbStr(1, 'event_log_start'),
|
|
130
|
+
pbInt(3, now)
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const checkin = Buffer.concat([
|
|
134
|
+
pbMsg(1, build),
|
|
135
|
+
pbInt(2, 0),
|
|
136
|
+
pbMsg(3, event),
|
|
137
|
+
pbInt(9, 0)
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
// 53 bits of randomness rather than a full 63: it stays an exact JS integer,
|
|
141
|
+
// and Google only needs the value to be unlikely to collide.
|
|
142
|
+
const loggingId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
|
143
|
+
|
|
144
|
+
return Buffer.concat([
|
|
145
|
+
pbInt(2, 0),
|
|
146
|
+
pbMsg(4, checkin),
|
|
147
|
+
pbStr(6, 'en_US'),
|
|
148
|
+
pbInt(7, loggingId),
|
|
149
|
+
pbStr(12, 'UTC'),
|
|
150
|
+
pbInt(14, 3),
|
|
151
|
+
pbInt(20, 0),
|
|
152
|
+
pbInt(22, 0)
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Only two fields matter in the answer, both fixed64: 7 = android id,
|
|
157
|
+
// 8 = security token. They are 64-bit and are echoed back to Google verbatim,
|
|
158
|
+
// so they are kept as BigInt and only ever stringified — a Number would round
|
|
159
|
+
// them and the Authorization header would be silently wrong.
|
|
160
|
+
function decodeCheckinResponse(buf) {
|
|
161
|
+
let i = 0;
|
|
162
|
+
const out = { androidId: null, securityToken: null };
|
|
163
|
+
|
|
164
|
+
const readVarint = () => {
|
|
165
|
+
let result = 0n, shift = 0n;
|
|
166
|
+
while (i < buf.length) {
|
|
167
|
+
const b = buf[i++];
|
|
168
|
+
result |= BigInt(b & 0x7f) << shift;
|
|
169
|
+
shift += 7n;
|
|
170
|
+
if (!(b & 0x80)) break;
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
while (i < buf.length) {
|
|
176
|
+
const key = Number(readVarint());
|
|
177
|
+
const field = key >> 3;
|
|
178
|
+
const wire = key & 7;
|
|
179
|
+
|
|
180
|
+
if (wire === 0) { readVarint(); }
|
|
181
|
+
else if (wire === 1) {
|
|
182
|
+
if (i + 8 > buf.length) break;
|
|
183
|
+
const v = buf.readBigUInt64LE(i);
|
|
184
|
+
i += 8;
|
|
185
|
+
if (field === 7) out.androidId = v;
|
|
186
|
+
if (field === 8) out.securityToken = v;
|
|
187
|
+
}
|
|
188
|
+
else if (wire === 2) {
|
|
189
|
+
const len = Number(readVarint());
|
|
190
|
+
i += len;
|
|
191
|
+
}
|
|
192
|
+
else if (wire === 5) { i += 4; }
|
|
193
|
+
else break;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── HTTP ────────────────────────────────────────────────────────────────────
|
|
199
|
+
//
|
|
200
|
+
// Routed through the SOCKS agent when one is configured, like every other
|
|
201
|
+
// outbound call in the library — a machine that needs a proxy to reach WhatsApp
|
|
202
|
+
// generally needs one to reach Google too.
|
|
203
|
+
|
|
204
|
+
function httpPost(url, body, headers) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const u = new URL(url);
|
|
207
|
+
const agent = proxyAgent();
|
|
208
|
+
const req = https.request({
|
|
209
|
+
hostname: u.hostname,
|
|
210
|
+
port: u.port || 443,
|
|
211
|
+
path: u.pathname + u.search,
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: Object.assign({ 'Content-Length': body.length }, headers),
|
|
214
|
+
agent: agent || undefined
|
|
215
|
+
}, res => {
|
|
216
|
+
const chunks = [];
|
|
217
|
+
res.on('data', c => chunks.push(c));
|
|
218
|
+
res.on('end', () => {
|
|
219
|
+
let raw = Buffer.concat(chunks);
|
|
220
|
+
if (String(res.headers['content-encoding'] || '').includes('gzip')) {
|
|
221
|
+
try { raw = zlib.gunzipSync(raw); } catch (_) {}
|
|
222
|
+
}
|
|
223
|
+
resolve({ status: res.statusCode || 0, body: raw });
|
|
224
|
+
});
|
|
225
|
+
res.on('error', reject);
|
|
226
|
+
});
|
|
227
|
+
req.on('error', reject);
|
|
228
|
+
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error('timed out')));
|
|
229
|
+
req.write(body);
|
|
230
|
+
req.end();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── the three steps ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
async function checkin() {
|
|
237
|
+
const body = zlib.gzipSync(encodeCheckinRequest());
|
|
238
|
+
const res = await httpPost(CHECKIN_URL, body, {
|
|
239
|
+
'Content-Type': 'application/x-protobuffer',
|
|
240
|
+
'Content-Encoding': 'gzip',
|
|
241
|
+
'Accept-Encoding': 'gzip',
|
|
242
|
+
'User-Agent': 'Android-Checkin/2.0 (generic JLS36G); gzip'
|
|
243
|
+
});
|
|
244
|
+
if (res.status < 200 || res.status >= 300) {
|
|
245
|
+
throw new Error('checkin HTTP ' + res.status);
|
|
246
|
+
}
|
|
247
|
+
const { androidId, securityToken } = decodeCheckinResponse(res.body);
|
|
248
|
+
if (!androidId || !securityToken) {
|
|
249
|
+
throw new Error('checkin returned no android id (' + res.body.length + ' B)');
|
|
250
|
+
}
|
|
251
|
+
return { androidId: androidId.toString(), securityToken: securityToken.toString() };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function generateFid() {
|
|
255
|
+
const raw = crypto.randomBytes(FID_BYTES);
|
|
256
|
+
raw[0] = 0b01110000 | (raw[0] & 0b00001111);
|
|
257
|
+
return raw.toString('base64url').slice(0, FID_LENGTH);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function fisInstall(config, existingFid) {
|
|
261
|
+
const fid = existingFid || generateFid();
|
|
262
|
+
const body = Buffer.from(JSON.stringify({
|
|
263
|
+
fid,
|
|
264
|
+
appId: config.appId,
|
|
265
|
+
authVersion: 'FIS_v2',
|
|
266
|
+
sdkVersion: 'a:17.0.0'
|
|
267
|
+
}), 'utf8');
|
|
268
|
+
|
|
269
|
+
const res = await httpPost(FIS_URL.replace('%s', config.projectId), body, {
|
|
270
|
+
'Content-Type': 'application/json',
|
|
271
|
+
'Accept': 'application/json',
|
|
272
|
+
'Cache-Control': 'no-cache',
|
|
273
|
+
'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 11; Pixel 2 Build/RP1A.201005.004) FirebaseInstallations/17.0.0',
|
|
274
|
+
'X-Firebase-Client': Buffer.from('{"heartbeats":[],"version":2}', 'utf8').toString('base64url'),
|
|
275
|
+
'x-goog-api-key': config.apiKey,
|
|
276
|
+
'X-Android-Package': config.packageName,
|
|
277
|
+
'X-Android-Cert': config.certSha1.replace(/:/g, '').toUpperCase()
|
|
278
|
+
});
|
|
279
|
+
if (res.status < 200 || res.status >= 300) {
|
|
280
|
+
throw new Error('FIS install HTTP ' + res.status + ': ' + res.body.toString('utf8').slice(0, 200));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let parsed;
|
|
284
|
+
try { parsed = JSON.parse(res.body.toString('utf8')); }
|
|
285
|
+
catch (_) { throw new Error('FIS install returned non-JSON'); }
|
|
286
|
+
|
|
287
|
+
const auth = parsed.authToken || {};
|
|
288
|
+
return {
|
|
289
|
+
fid: parsed.fid || fid,
|
|
290
|
+
authToken: auth.token || '',
|
|
291
|
+
refreshToken: parsed.refreshToken || '',
|
|
292
|
+
expiresAt: Math.floor(Date.now() / 1000) + parseDurationSeconds(auth.expiresIn)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Google writes durations as "604800s". A missing or malformed value becomes 0,
|
|
297
|
+
// which reads as already expired and simply forces a refresh next time.
|
|
298
|
+
function parseDurationSeconds(text) {
|
|
299
|
+
const m = /^(\d+)s$/.exec(String(text || ''));
|
|
300
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function register3(config, session) {
|
|
304
|
+
const form = {
|
|
305
|
+
app: config.packageName,
|
|
306
|
+
sender: config.senderId,
|
|
307
|
+
device: session.androidId,
|
|
308
|
+
cert: config.certSha1.replace(/:/g, '').toLowerCase(),
|
|
309
|
+
app_ver: '1',
|
|
310
|
+
target_ver: '30',
|
|
311
|
+
info: '',
|
|
312
|
+
'X-subtype': config.senderId,
|
|
313
|
+
'X-subscription': config.senderId,
|
|
314
|
+
'X-app_ver': '1',
|
|
315
|
+
'X-osv': '30',
|
|
316
|
+
'X-cliv': 'fiid-21.1.0',
|
|
317
|
+
'X-gmsv': '240913000',
|
|
318
|
+
'X-scope': '*',
|
|
319
|
+
'X-appid': session.fid,
|
|
320
|
+
'X-gmp_app_id': config.appId,
|
|
321
|
+
'X-Goog-Firebase-Installations-Auth': session.fisAuthToken,
|
|
322
|
+
'X-firebase-app-name-hash': ''
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const body = Buffer.from(
|
|
326
|
+
Object.keys(form)
|
|
327
|
+
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(form[k]))
|
|
328
|
+
.join('&'),
|
|
329
|
+
'utf8'
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const res = await httpPost(REGISTER_URL, body, {
|
|
333
|
+
'Authorization': 'AidLogin ' + session.androidId + ':' + session.securityToken,
|
|
334
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
335
|
+
'User-Agent': 'Android-GCM/1.5 (generic LRX22C)',
|
|
336
|
+
'app': config.packageName
|
|
337
|
+
});
|
|
338
|
+
if (res.status < 200 || res.status >= 300) {
|
|
339
|
+
throw new Error('register3 HTTP ' + res.status + ': ' + res.body.toString('utf8').slice(0, 200));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The answer is a flat key=value list; anything other than a token line is
|
|
343
|
+
// an error Google chose to report with a 200.
|
|
344
|
+
const text = res.body.toString('utf8').trim();
|
|
345
|
+
for (const line of text.split('\n')) {
|
|
346
|
+
const eq = line.indexOf('=');
|
|
347
|
+
if (eq > 0 && line.slice(0, eq).trim() === 'token') {
|
|
348
|
+
const token = line.slice(eq + 1).trim();
|
|
349
|
+
if (token) return token;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
throw new Error('register3 returned no token: ' + text.slice(0, 200));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── public entry point ──────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
/** Whether push-token acquisition is switched on. Opt out with WA_FCM_PUSH=0. */
|
|
358
|
+
function enabled() {
|
|
359
|
+
const v = process.env.WA_FCM_PUSH;
|
|
360
|
+
return v === undefined || !(v === '0' || v.toLowerCase() === 'false' || v === 'off');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A token in hand is reused. Google mints an android id once and expects it
|
|
364
|
+
// back; running checkin again per registration step would present a new phantom
|
|
365
|
+
// device every time.
|
|
366
|
+
function cached(store) {
|
|
367
|
+
const s = store && store.fcm;
|
|
368
|
+
return s && s.token ? s.token : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The FCM push token for this account, or null.
|
|
373
|
+
*
|
|
374
|
+
* Never throws and never rejects: every failure path ends as null, which
|
|
375
|
+
* attestationFields turns into an omitted field and buildForm leaves out of the
|
|
376
|
+
* body entirely — byte for byte what registration sent before this existed.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} store the account store; the session is cached on store.fcm
|
|
379
|
+
* @param {object} device device config; device.business picks the w4b package
|
|
380
|
+
* @returns {Promise<string|null>}
|
|
381
|
+
*/
|
|
382
|
+
async function getPushToken(store, device) {
|
|
383
|
+
if (!enabled()) return null;
|
|
384
|
+
if (!store) return null;
|
|
385
|
+
|
|
386
|
+
const hit = cached(store);
|
|
387
|
+
if (hit) {
|
|
388
|
+
_whaDbg('[DBG] FCM push token from cache, length=' + hit.length);
|
|
389
|
+
return hit;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const config = (device && device.business) ? FCM_CONFIG.business : FCM_CONFIG.personal;
|
|
393
|
+
const prev = store.fcm || {};
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
let androidId = prev.androidId;
|
|
397
|
+
let securityToken = prev.securityToken;
|
|
398
|
+
|
|
399
|
+
if (!androidId || !securityToken) {
|
|
400
|
+
_whaDbg('[DBG] FCM step 1/3 checkin');
|
|
401
|
+
const ids = await checkin();
|
|
402
|
+
androidId = ids.androidId;
|
|
403
|
+
securityToken = ids.securityToken;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
_whaDbg('[DBG] FCM step 2/3 FIS install (' + config.packageName + ')');
|
|
407
|
+
const fis = await fisInstall(config, prev.fid);
|
|
408
|
+
|
|
409
|
+
_whaDbg('[DBG] FCM step 3/3 register3');
|
|
410
|
+
const token = await register3(config, {
|
|
411
|
+
androidId, securityToken,
|
|
412
|
+
fid: fis.fid,
|
|
413
|
+
fisAuthToken: fis.authToken
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
store.fcm = {
|
|
417
|
+
androidId, securityToken,
|
|
418
|
+
fid: fis.fid,
|
|
419
|
+
fisAuthToken: fis.authToken,
|
|
420
|
+
fisRefreshToken: fis.refreshToken,
|
|
421
|
+
fisExpiresAt: fis.expiresAt,
|
|
422
|
+
token
|
|
423
|
+
};
|
|
424
|
+
_whaDbg('[DBG] FCM push token acquired, length=' + token.length);
|
|
425
|
+
return token;
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// Keep whatever identity was established: a checkin that succeeded before a
|
|
428
|
+
// later step failed is worth carrying into the next attempt rather than
|
|
429
|
+
// minting a second android id.
|
|
430
|
+
_whaDbg('[DBG] FCM push token unavailable: ' + (err && err.message) +
|
|
431
|
+
' — registering without one');
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
getPushToken,
|
|
438
|
+
enabled,
|
|
439
|
+
FCM_CONFIG,
|
|
440
|
+
// exported for tests
|
|
441
|
+
encodeCheckinRequest,
|
|
442
|
+
decodeCheckinResponse,
|
|
443
|
+
generateFid,
|
|
444
|
+
parseDurationSeconds
|
|
445
|
+
};
|
package/lib/noise.js
CHANGED
|
@@ -370,15 +370,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
370
370
|
this._connectResolve = resolve;
|
|
371
371
|
this._connectReject = reject;
|
|
372
372
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
this.socket = new WebSocketStream().connect();
|
|
376
|
-
} else {
|
|
377
|
-
this.socket = net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT });
|
|
378
|
-
}
|
|
379
|
-
// OS-level TCP keepalive: detects silently-dead NAT connections that never send FIN/RST
|
|
380
|
-
this.socket.setKeepAlive(true, 15000);
|
|
381
|
-
// Connect-phase watchdog: abort if TCP handshake takes >20s
|
|
373
|
+
// Armed before the socket exists so it covers a SOCKS handshake that
|
|
374
|
+
// never completes, not just a TCP one.
|
|
382
375
|
this._connectTimeoutTimer = setTimeout(() => {
|
|
383
376
|
if (this._connectReject) {
|
|
384
377
|
const err = new Error('WA TCP connect timeout (20s)');
|
|
@@ -386,16 +379,55 @@ class NoiseSocket extends EventEmitter {
|
|
|
386
379
|
this._connectResolve = null;
|
|
387
380
|
this._connectReject = null;
|
|
388
381
|
}
|
|
389
|
-
try { this.socket.destroy(); } catch (_) {}
|
|
382
|
+
try { if (this.socket) this.socket.destroy(); } catch (_) {}
|
|
390
383
|
}, 20000);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
384
|
+
|
|
385
|
+
// `alreadyConnected` is the SOCKS case: the proxy hands back a socket
|
|
386
|
+
// whose handshake is finished, so no 'connect' event will ever fire on
|
|
387
|
+
// it and waiting for one would stall until the watchdog fires.
|
|
388
|
+
const attach = (sock, alreadyConnected) => {
|
|
389
|
+
this.socket = sock;
|
|
390
|
+
// OS-level TCP keepalive: detects silently-dead NAT connections that
|
|
391
|
+
// never send FIN/RST.
|
|
392
|
+
sock.setKeepAlive(true, 15000);
|
|
393
|
+
|
|
394
|
+
sock.on('data', (data) => this._onData(data));
|
|
395
|
+
sock.on('error', (err) => this._onTcpError(err));
|
|
396
|
+
sock.on('close', () => this._onTcpClose());
|
|
397
|
+
|
|
398
|
+
const onUp = () => {
|
|
399
|
+
clearTimeout(this._connectTimeoutTimer);
|
|
400
|
+
this._connectTimeoutTimer = null;
|
|
401
|
+
this._onTcpConnect();
|
|
402
|
+
};
|
|
403
|
+
if (alreadyConnected) onUp();
|
|
404
|
+
else sock.on('connect', onUp);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (this.transport === 'web') {
|
|
408
|
+
const { WebSocketStream } = require('./WebSocketStream');
|
|
409
|
+
attach(new WebSocketStream().connect(), false);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Mobile is a raw TCP socket, and net.createConnection has no notion of
|
|
414
|
+
// a proxy. Dial through SOCKS when one is configured so the mobile
|
|
415
|
+
// transport is reachable from the same networks the web one is.
|
|
416
|
+
const { socksProxyUrl, socksConnect } = require('./socks');
|
|
417
|
+
const proxyUrl = socksProxyUrl();
|
|
418
|
+
if (!proxyUrl) {
|
|
419
|
+
attach(net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT }), false);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_whaDbg('[DBG] TCP via SOCKS proxy → ' + WHATSAPP_HOST + ':' + WHATSAPP_PORT);
|
|
424
|
+
socksConnect(proxyUrl, WHATSAPP_HOST, WHATSAPP_PORT)
|
|
425
|
+
.then(sock => attach(sock, true))
|
|
426
|
+
.catch(err => {
|
|
427
|
+
clearTimeout(this._connectTimeoutTimer);
|
|
428
|
+
this._connectTimeoutTimer = null;
|
|
429
|
+
this._onTcpError(err);
|
|
430
|
+
});
|
|
399
431
|
});
|
|
400
432
|
}
|
|
401
433
|
|
package/lib/socks.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// SOCKS4 / SOCKS5 / Tor support for every outbound connection the library makes.
|
|
4
|
+
//
|
|
5
|
+
// Set SOCKS_PROXY (or TOR_PROXY) and everything goes through it:
|
|
6
|
+
//
|
|
7
|
+
// socks5://127.0.0.1:9050 Tor
|
|
8
|
+
// socks5://user:pass@host:1080 authenticated residential proxy
|
|
9
|
+
// socks5h://host:1080 resolve the destination at the proxy
|
|
10
|
+
// socks4://host:1080 older proxies
|
|
11
|
+
//
|
|
12
|
+
// Requires the `socks` package, which ships as an optional dependency.
|
|
13
|
+
//
|
|
14
|
+
// Why this module exists at all: the proxy used to be honoured by exactly one
|
|
15
|
+
// call site, the registration POST. Everything else — the App Store and Play
|
|
16
|
+
// Store version lookups, the web client's sw.js revision probe, the WhatsApp
|
|
17
|
+
// Web socket and the mobile TCP socket — went out through `https.get`,
|
|
18
|
+
// `axios` and `net.createConnection`, none of which look at proxy environment
|
|
19
|
+
// variables. Node has no built-in proxy support; an agent has to be handed in.
|
|
20
|
+
//
|
|
21
|
+
// The visible symptom was a connection that "worked" in curl and timed out in
|
|
22
|
+
// the library:
|
|
23
|
+
//
|
|
24
|
+
// [DBG] WEB_VERSION 2.3000.1035194821 (pinned fallback: sw.js fetch timed out)
|
|
25
|
+
//
|
|
26
|
+
// curl honours SOCKS_PROXY, https.get does not. Same machine, same proxy,
|
|
27
|
+
// opposite results.
|
|
28
|
+
|
|
29
|
+
const https = require('https');
|
|
30
|
+
const tls = require('tls');
|
|
31
|
+
|
|
32
|
+
const SOCKS_SCHEMES = {
|
|
33
|
+
'socks:': 5,
|
|
34
|
+
'socks5:': 5,
|
|
35
|
+
'socks5h:': 5,
|
|
36
|
+
'socks4:': 4,
|
|
37
|
+
'socks4a:': 4
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// How long a SOCKS handshake may take before it is treated as dead. A proxy
|
|
41
|
+
// that accepts the TCP connection and then says nothing would otherwise hang
|
|
42
|
+
// whatever asked for the connection.
|
|
43
|
+
const SOCKS_TIMEOUT_MS = 20000;
|
|
44
|
+
|
|
45
|
+
// Resolve the library the way every other dependency is resolved.
|
|
46
|
+
//
|
|
47
|
+
// WA_SOCKS_LIB stays as an escape hatch for unusual layouts, but it is an
|
|
48
|
+
// override, not the only way in.
|
|
49
|
+
function loadSocks() {
|
|
50
|
+
const override = process.env.WA_SOCKS_LIB;
|
|
51
|
+
if (override) {
|
|
52
|
+
try { return require(override); }
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error('WA_SOCKS_LIB points at ' + override +
|
|
55
|
+
' but it could not be loaded: ' + err.message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return require('socks');
|
|
60
|
+
} catch (_) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"A SOCKS proxy is configured but the 'socks' package is not installed. " +
|
|
63
|
+
'Run: npm install socks'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a proxy URL into what SocksClient expects.
|
|
70
|
+
*
|
|
71
|
+
* Accepts the schemes above, with or without credentials, and defaults the
|
|
72
|
+
* port to 1080 the way every SOCKS client does.
|
|
73
|
+
*/
|
|
74
|
+
function parseSocksProxy(proxyUrl) {
|
|
75
|
+
let raw = String(proxyUrl || '').trim();
|
|
76
|
+
if (!raw) throw new Error('No SOCKS proxy configured');
|
|
77
|
+
|
|
78
|
+
// A bare host:port is what .env.example has always shown and what most people
|
|
79
|
+
// type. Without a scheme `new URL` reads "127.0.0.1:" as the protocol and the
|
|
80
|
+
// rest as the path, so it has to be filled in before parsing.
|
|
81
|
+
if (!/^[a-z0-9+.-]+:\/\//i.test(raw)) raw = 'socks5://' + raw;
|
|
82
|
+
|
|
83
|
+
let url;
|
|
84
|
+
try { url = new URL(raw); }
|
|
85
|
+
catch (_) { throw new Error('Invalid SOCKS proxy URL: ' + proxyUrl); }
|
|
86
|
+
|
|
87
|
+
const type = SOCKS_SCHEMES[url.protocol];
|
|
88
|
+
if (!type) {
|
|
89
|
+
throw new Error('Unsupported proxy scheme "' + url.protocol + '" — use ' +
|
|
90
|
+
Object.keys(SOCKS_SCHEMES).map(s => s.replace(':', '')).join(', '));
|
|
91
|
+
}
|
|
92
|
+
if (!url.hostname) throw new Error('SOCKS proxy URL has no host: ' + proxyUrl);
|
|
93
|
+
|
|
94
|
+
const proxy = {
|
|
95
|
+
host: url.hostname,
|
|
96
|
+
port: parseInt(url.port, 10) || 1080,
|
|
97
|
+
type
|
|
98
|
+
};
|
|
99
|
+
// Credentials may be percent-encoded in a URL; a password with an @ or a :
|
|
100
|
+
// in it is common enough on paid proxies to be worth decoding.
|
|
101
|
+
if (url.username) proxy.userId = decodeURIComponent(url.username);
|
|
102
|
+
if (url.password) proxy.password = decodeURIComponent(url.password);
|
|
103
|
+
return proxy;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// TOR_PROXY wins when both are set — the precedence .env.example has always
|
|
107
|
+
// documented. Read from the environment on every call rather than cached at
|
|
108
|
+
// require time, so a process that sets it late still gets it.
|
|
109
|
+
function socksProxyUrl() {
|
|
110
|
+
return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether a proxy is configured at all. Cheap, and never throws. */
|
|
114
|
+
function hasProxy() {
|
|
115
|
+
return !!socksProxyUrl();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Open a raw TCP connection to host:port through the configured proxy.
|
|
120
|
+
*
|
|
121
|
+
* Resolves with a connected socket — the SOCKS handshake is already done, so
|
|
122
|
+
* no 'connect' event will fire on it. Callers that key off 'connect' have to
|
|
123
|
+
* account for that.
|
|
124
|
+
*/
|
|
125
|
+
async function socksConnect(proxyUrl, host, port, timeoutMs) {
|
|
126
|
+
const proxy = parseSocksProxy(proxyUrl);
|
|
127
|
+
const { SocksClient } = loadSocks();
|
|
128
|
+
const { socket } = await SocksClient.createConnection({
|
|
129
|
+
proxy,
|
|
130
|
+
command: 'connect',
|
|
131
|
+
destination: { host, port },
|
|
132
|
+
timeout: timeoutMs > 0 ? timeoutMs : SOCKS_TIMEOUT_MS
|
|
133
|
+
});
|
|
134
|
+
return socket;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An https.Agent that tunnels through SOCKS.
|
|
139
|
+
*
|
|
140
|
+
* `https.get`, `axios` and `ws` all accept an agent and all drive it the same
|
|
141
|
+
* way, so one of these covers every HTTPS and WSS call site in the library.
|
|
142
|
+
*
|
|
143
|
+
* The base agent's createConnection calls tls.connect on a fresh TCP socket;
|
|
144
|
+
* this one gets the TCP socket from the proxy first and layers TLS on top of
|
|
145
|
+
* it. Everything else — pooling, keep-alive, timeouts — is inherited.
|
|
146
|
+
*/
|
|
147
|
+
class SocksHttpsAgent extends https.Agent {
|
|
148
|
+
constructor(proxyUrl, opts) {
|
|
149
|
+
super(opts);
|
|
150
|
+
this._proxyUrl = proxyUrl;
|
|
151
|
+
// Fail fast on a malformed URL, at construction rather than on first use,
|
|
152
|
+
// so a typo surfaces as a clear error instead of a stalled request.
|
|
153
|
+
this._proxy = parseSocksProxy(proxyUrl);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
createConnection(options, callback) {
|
|
157
|
+
const { SocksClient } = loadSocks();
|
|
158
|
+
const host = options.host || options.hostname;
|
|
159
|
+
const port = Number(options.port) || 443;
|
|
160
|
+
|
|
161
|
+
SocksClient.createConnection({
|
|
162
|
+
proxy: this._proxy,
|
|
163
|
+
command: 'connect',
|
|
164
|
+
destination: { host, port },
|
|
165
|
+
timeout: SOCKS_TIMEOUT_MS
|
|
166
|
+
}).then(({ socket }) => {
|
|
167
|
+
// servername has to be set explicitly: the TLS socket is built on a
|
|
168
|
+
// socket that was never given a hostname, so without it SNI goes out
|
|
169
|
+
// empty and WhatsApp's edge answers with the wrong certificate.
|
|
170
|
+
const tlsSocket = tls.connect(Object.assign({}, options, {
|
|
171
|
+
socket,
|
|
172
|
+
host,
|
|
173
|
+
port,
|
|
174
|
+
servername: options.servername || (typeof host === 'string' ? host : undefined)
|
|
175
|
+
}));
|
|
176
|
+
callback(null, tlsSocket);
|
|
177
|
+
}).catch(err => callback(err));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One agent per proxy URL. Agents pool sockets, so building a fresh one per
|
|
182
|
+
// request would throw the pool away every time.
|
|
183
|
+
const _agentCache = new Map();
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The agent for a given proxy URL, or null when none is configured.
|
|
187
|
+
*
|
|
188
|
+
* Never throws: a proxy that cannot be parsed, or a missing `socks` package,
|
|
189
|
+
* returns null and leaves the caller on a direct connection. A version lookup
|
|
190
|
+
* that has a pinned fallback must not take the process down over its proxy.
|
|
191
|
+
*/
|
|
192
|
+
function socksAgent(proxyUrl) {
|
|
193
|
+
const url = proxyUrl || socksProxyUrl();
|
|
194
|
+
if (!url) return null;
|
|
195
|
+
if (_agentCache.has(url)) return _agentCache.get(url);
|
|
196
|
+
|
|
197
|
+
let agent = null;
|
|
198
|
+
try {
|
|
199
|
+
agent = new SocksHttpsAgent(url, { keepAlive: false });
|
|
200
|
+
} catch (_) {
|
|
201
|
+
agent = null;
|
|
202
|
+
}
|
|
203
|
+
_agentCache.set(url, agent);
|
|
204
|
+
return agent;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The agent for the environment's proxy, or null. */
|
|
208
|
+
function proxyAgent() {
|
|
209
|
+
return socksAgent(socksProxyUrl());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Drop cached agents. For tests, and for a process that swaps proxies. */
|
|
213
|
+
function clearAgentCache() {
|
|
214
|
+
for (const agent of _agentCache.values()) {
|
|
215
|
+
if (agent && typeof agent.destroy === 'function') {
|
|
216
|
+
try { agent.destroy(); } catch (_) {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
_agentCache.clear();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
SOCKS_SCHEMES,
|
|
224
|
+
SOCKS_TIMEOUT_MS,
|
|
225
|
+
loadSocks,
|
|
226
|
+
parseSocksProxy,
|
|
227
|
+
socksProxyUrl,
|
|
228
|
+
hasProxy,
|
|
229
|
+
socksConnect,
|
|
230
|
+
socksAgent,
|
|
231
|
+
proxyAgent,
|
|
232
|
+
clearAgentCache,
|
|
233
|
+
SocksHttpsAgent
|
|
234
|
+
};
|