whalibmob 5.19.2 → 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 +12 -0
- package/README.md +21 -0
- package/lib/Registration.js +7 -1
- package/lib/Store.js +7 -0
- package/lib/fcm.js +445 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -144,6 +144,18 @@
|
|
|
144
144
|
# Only if `socks` lives somewhere require() cannot find it:
|
|
145
145
|
# WA_SOCKS_LIB=/absolute/path/to/socks/build/index.js
|
|
146
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
|
+
|
|
147
159
|
|
|
148
160
|
# ─── Registration funnel telemetry ───────────────────────────────────────────
|
|
149
161
|
# Registration reports the screens it passes through to WhatsApp's /client_log,
|
package/README.md
CHANGED
|
@@ -152,6 +152,7 @@ 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
|
+
- [The Push Token](#the-push-token)
|
|
155
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)
|
|
@@ -2488,6 +2489,26 @@ 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
|
|
|
2492
|
+
## The Push Token
|
|
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
|
+
|
|
2491
2512
|
## Routing Traffic Through a Proxy
|
|
2492
2513
|
|
|
2493
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.
|
package/lib/Registration.js
CHANGED
|
@@ -1257,7 +1257,13 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
1257
1257
|
.update(Buffer.concat([store.identityId, stripKeyPrefix(store.noiseKeyPair.public)]))
|
|
1258
1258
|
.digest()
|
|
1259
1259
|
);
|
|
1260
|
-
|
|
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 });
|
|
1261
1267
|
|
|
1262
1268
|
return buildForm([
|
|
1263
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/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
|
+
};
|