whalibmob 5.12.10 → 5.12.13
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 +30 -2
- package/README.md +156 -0
- package/cli.js +107 -7
- package/lib/AndroidApk.js +462 -0
- package/lib/PlayStore.js +381 -0
- package/lib/Registration.js +162 -8
- package/lib/constants.js +6 -3
- package/lib/playstore-device.js +45 -0
- package/package.json +2 -2
package/lib/PlayStore.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fetching the WhatsApp APK from Google Play, the way Cobalt's PlayStoreUtils
|
|
4
|
+
// does it, so the Android token material can be refreshed without anyone having
|
|
5
|
+
// to find an APK by hand.
|
|
6
|
+
//
|
|
7
|
+
// The route, in three hops:
|
|
8
|
+
//
|
|
9
|
+
// 1. POST an Aurora-style device profile to the Aurora OSS token dispenser,
|
|
10
|
+
// which mints an anonymous Google account and hands back a Play auth token
|
|
11
|
+
// plus the device metadata that has to accompany it. No Google account of
|
|
12
|
+
// the caller's is involved at any point.
|
|
13
|
+
// 2. GET /fdfe/details for the version, then /fdfe/purchase to acquire the
|
|
14
|
+
// free app, then /fdfe/delivery for the signed CDN URLs. All three answer
|
|
15
|
+
// in protobuf.
|
|
16
|
+
// 3. GET each URL with the delivery cookies attached, which yields the base
|
|
17
|
+
// APK and every split.
|
|
18
|
+
//
|
|
19
|
+
// Two things are worth knowing before relying on this. The dispenser is a free
|
|
20
|
+
// third-party service — when it is down or rate limited, so is this, and the
|
|
21
|
+
// APK on a phone is always available where this is not. And a current WhatsApp
|
|
22
|
+
// bundle is well over a hundred megabytes across its splits, which is a real
|
|
23
|
+
// download on a phone connection; only the density splits are actually needed
|
|
24
|
+
// for the token, so this fetches only those.
|
|
25
|
+
|
|
26
|
+
const https = require('https');
|
|
27
|
+
const { URL } = require('url');
|
|
28
|
+
const DEVICE_PROFILE = require('./playstore-device');
|
|
29
|
+
const { dbg: _whaDbg } = require('./logger');
|
|
30
|
+
|
|
31
|
+
const AURORA_DISPENSER_URL = 'https://auroraoss.com/api/auth';
|
|
32
|
+
// The dispenser refuses a request with an arbitrary user-agent.
|
|
33
|
+
const AURORA_USER_AGENT = 'com.aurora.store-4.6.1-70';
|
|
34
|
+
|
|
35
|
+
const FDFE_BASE_URL = 'https://android.clients.google.com/fdfe';
|
|
36
|
+
const DETAILS_URL = FDFE_BASE_URL + '/details';
|
|
37
|
+
const PURCHASE_URL = FDFE_BASE_URL + '/purchase';
|
|
38
|
+
const DELIVERY_URL = FDFE_BASE_URL + '/delivery';
|
|
39
|
+
|
|
40
|
+
// Which Play protocol features the client claims to understand. The same blob
|
|
41
|
+
// gplaydl and the Aurora Store ship; required on every FDFE call.
|
|
42
|
+
const X_DFE_ENCODED_TARGETS =
|
|
43
|
+
'CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxXrBPsDlQUdMfOLAfoFrwEHgAcBrQYhoA0cGt4MKK0Y2gI';
|
|
44
|
+
|
|
45
|
+
const FALLBACK_FINSKY_USER_AGENT =
|
|
46
|
+
'Android-Finsky/41.2.29-23 [0] [PR] 639844241 ' +
|
|
47
|
+
'(api=3,versionCode=84122900,sdk=34,device=lynx,' +
|
|
48
|
+
'hardware=lynx,product=lynx,platformVersionRelease=14,' +
|
|
49
|
+
'model=Pixel%207a,buildId=UQ1A.231205.015,' +
|
|
50
|
+
'isWideScreen=0,supportedAbis=arm64-v8a;armeabi-v7a;armeabi)';
|
|
51
|
+
|
|
52
|
+
const LOCALE = 'en_US';
|
|
53
|
+
const REQUEST_TIMEOUT = 120000;
|
|
54
|
+
|
|
55
|
+
const PERSONAL_PACKAGE = 'com.whatsapp';
|
|
56
|
+
|
|
57
|
+
// ─── protobuf ─────────────────────────────────────────────────────────────────
|
|
58
|
+
//
|
|
59
|
+
// Only enough of the wire format to walk to a handful of known fields. Every
|
|
60
|
+
// message here is read by field number, and anything unrecognised is skipped —
|
|
61
|
+
// which is most of what Play returns.
|
|
62
|
+
|
|
63
|
+
function _readVarint(buf, pos) {
|
|
64
|
+
let result = 0, shift = 0, byte;
|
|
65
|
+
do {
|
|
66
|
+
if (pos >= buf.length) throw new Error('truncated varint');
|
|
67
|
+
byte = buf[pos++];
|
|
68
|
+
result += (byte & 0x7f) * Math.pow(2, shift);
|
|
69
|
+
shift += 7;
|
|
70
|
+
} while (byte & 0x80);
|
|
71
|
+
return { value: result, pos };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// field number → array of values. Length-delimited fields come back as Buffers,
|
|
75
|
+
// varints as numbers; everything else is skipped.
|
|
76
|
+
function decodeFields(buf) {
|
|
77
|
+
const out = new Map();
|
|
78
|
+
let pos = 0;
|
|
79
|
+
while (pos < buf.length) {
|
|
80
|
+
let key;
|
|
81
|
+
try { ({ value: key, pos } = _readVarint(buf, pos)); } catch (_) { break; }
|
|
82
|
+
const field = Math.floor(key / 8);
|
|
83
|
+
const wireType = key & 7;
|
|
84
|
+
let value;
|
|
85
|
+
if (wireType === 0) {
|
|
86
|
+
({ value, pos } = _readVarint(buf, pos));
|
|
87
|
+
} else if (wireType === 2) {
|
|
88
|
+
let len;
|
|
89
|
+
({ value: len, pos } = _readVarint(buf, pos));
|
|
90
|
+
if (pos + len > buf.length) break;
|
|
91
|
+
value = buf.slice(pos, pos + len);
|
|
92
|
+
pos += len;
|
|
93
|
+
} else if (wireType === 5) {
|
|
94
|
+
value = buf.readUInt32LE(pos); pos += 4;
|
|
95
|
+
} else if (wireType === 1) {
|
|
96
|
+
value = buf.slice(pos, pos + 8); pos += 8;
|
|
97
|
+
} else {
|
|
98
|
+
break; // groups: not used by these messages
|
|
99
|
+
}
|
|
100
|
+
if (!out.has(field)) out.set(field, []);
|
|
101
|
+
out.get(field).push(value);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function _one(fields, n) {
|
|
107
|
+
const v = fields && fields.get(n);
|
|
108
|
+
return v && v.length ? v[0] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Walk a chain of message field numbers, e.g. msg(1).msg(2).msg(21).
|
|
112
|
+
function _walk(buf, path) {
|
|
113
|
+
let cur = buf;
|
|
114
|
+
for (const n of path) {
|
|
115
|
+
const next = _one(decodeFields(cur), n);
|
|
116
|
+
if (next === null || !Buffer.isBuffer(next)) return null;
|
|
117
|
+
cur = next;
|
|
118
|
+
}
|
|
119
|
+
return cur;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── HTTP ─────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function _request(url, opts) {
|
|
125
|
+
opts = opts || {};
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const u = new URL(url);
|
|
128
|
+
const req = https.request({
|
|
129
|
+
hostname: u.hostname,
|
|
130
|
+
port: u.port || 443,
|
|
131
|
+
path: u.pathname + u.search,
|
|
132
|
+
method: opts.method || 'GET',
|
|
133
|
+
headers: opts.headers || {},
|
|
134
|
+
timeout: REQUEST_TIMEOUT
|
|
135
|
+
}, (res) => {
|
|
136
|
+
// The CDN answers a signed URL with a redirect more often than not.
|
|
137
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location &&
|
|
138
|
+
(opts.redirects || 0) < 5) {
|
|
139
|
+
res.resume();
|
|
140
|
+
return resolve(_request(new URL(res.headers.location, url).toString(),
|
|
141
|
+
Object.assign({}, opts, { redirects: (opts.redirects || 0) + 1 })));
|
|
142
|
+
}
|
|
143
|
+
const chunks = [];
|
|
144
|
+
res.on('data', d => chunks.push(d));
|
|
145
|
+
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
|
|
146
|
+
});
|
|
147
|
+
req.on('error', reject);
|
|
148
|
+
req.on('timeout', () => req.destroy(new Error('timeout on ' + url)));
|
|
149
|
+
if (opts.body) req.write(opts.body);
|
|
150
|
+
req.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async function fetchAnonymousAuth() {
|
|
157
|
+
const body = JSON.stringify(DEVICE_PROFILE);
|
|
158
|
+
const res = await _request(AURORA_DISPENSER_URL, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
'User-Agent': AURORA_USER_AGENT,
|
|
163
|
+
'Content-Length': Buffer.byteLength(body)
|
|
164
|
+
},
|
|
165
|
+
body
|
|
166
|
+
});
|
|
167
|
+
if (res.status !== 200) {
|
|
168
|
+
throw new Error('the Aurora token dispenser answered HTTP ' + res.status +
|
|
169
|
+
' — it is a free third-party service and this is what it does when it is ' +
|
|
170
|
+
'down or rate limiting. Nothing is wrong with your setup; either try again ' +
|
|
171
|
+
'later or pass an APK path instead.');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let json;
|
|
175
|
+
try { json = JSON.parse(res.body.toString('utf8')); }
|
|
176
|
+
catch (_) { throw new Error('the Aurora token dispenser returned something that is not JSON'); }
|
|
177
|
+
if (!json || !json.authToken) {
|
|
178
|
+
throw new Error('the Aurora token dispenser returned no authToken');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const provider = json.deviceInfoProvider || {};
|
|
182
|
+
return {
|
|
183
|
+
authToken: json.authToken,
|
|
184
|
+
gsfId: json.gsfId || '',
|
|
185
|
+
dfeCookie: json.dfeCookie || '',
|
|
186
|
+
checkinConsistencyToken: json.deviceCheckInConsistencyToken || null,
|
|
187
|
+
deviceConfigToken: json.deviceConfigToken || null,
|
|
188
|
+
userAgent: provider.userAgentString || FALLBACK_FINSKY_USER_AGENT,
|
|
189
|
+
mccMnc: provider.mccMnc || null
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildFdfeHeaders(auth) {
|
|
194
|
+
const headers = {
|
|
195
|
+
'Authorization': 'Bearer ' + auth.authToken,
|
|
196
|
+
'User-Agent': auth.userAgent,
|
|
197
|
+
'X-DFE-Device-Id': auth.gsfId,
|
|
198
|
+
'Accept-Language': 'en-US',
|
|
199
|
+
'X-DFE-Encoded-Targets': X_DFE_ENCODED_TARGETS,
|
|
200
|
+
'X-DFE-Client-Id': 'am-android-google',
|
|
201
|
+
'X-DFE-Network-Type': '4',
|
|
202
|
+
'X-DFE-Content-Filters': '',
|
|
203
|
+
'X-Limit-Ad-Tracking-Enabled': 'false',
|
|
204
|
+
'X-Ad-Id': '',
|
|
205
|
+
'X-DFE-UserLanguages': LOCALE,
|
|
206
|
+
'X-DFE-Request-Params': 'timeoutMs=4000',
|
|
207
|
+
'X-DFE-Cookie': auth.dfeCookie,
|
|
208
|
+
'X-DFE-No-Prefetch': 'true'
|
|
209
|
+
};
|
|
210
|
+
if (auth.checkinConsistencyToken) headers['X-DFE-Device-Checkin-Consistency-Token'] = auth.checkinConsistencyToken;
|
|
211
|
+
if (auth.deviceConfigToken) headers['X-DFE-Device-Config-Token'] = auth.deviceConfigToken;
|
|
212
|
+
if (auth.mccMnc) headers['X-DFE-MCCMNC'] = auth.mccMnc;
|
|
213
|
+
return headers;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function _protoGet(url, headers) {
|
|
217
|
+
const res = await _request(url, {
|
|
218
|
+
headers: Object.assign({
|
|
219
|
+
'Content-Type': 'application/x-protobuf',
|
|
220
|
+
'Accept': 'application/x-protobuf'
|
|
221
|
+
}, headers)
|
|
222
|
+
});
|
|
223
|
+
if (res.status !== 200) throw new Error('GET ' + url + ' returned HTTP ' + res.status);
|
|
224
|
+
return res.body;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Catalogue ────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
// { versionCode, versionName } for a package, without downloading anything.
|
|
230
|
+
async function fetchVersion(packageName, headers) {
|
|
231
|
+
const raw = await _protoGet(DETAILS_URL + '?doc=' + encodeURIComponent(packageName), headers);
|
|
232
|
+
// ResponseWrapper(1).Payload → detailsResponse(2).docV2(4).docDetails(13).appDetails(1)
|
|
233
|
+
const details = _walk(raw, [1, 2, 4, 13, 1]);
|
|
234
|
+
if (!details) throw new Error('the Play details response carried no AppDetails for ' + packageName);
|
|
235
|
+
const fields = decodeFields(details);
|
|
236
|
+
const code = _one(fields, 3);
|
|
237
|
+
const name = _one(fields, 4);
|
|
238
|
+
if (code === null) throw new Error('the Play details response carried no versionCode for ' + packageName);
|
|
239
|
+
return { versionCode: code, versionName: name ? name.toString('utf8') : '' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The "Install" button. An app already associated with the rotated account
|
|
243
|
+
// answers 4xx, and the download works regardless, so failures are ignored.
|
|
244
|
+
async function acquire(packageName, versionCode, headers) {
|
|
245
|
+
const body = 'doc=' + encodeURIComponent(packageName) + '&ot=1&vc=' + versionCode;
|
|
246
|
+
try {
|
|
247
|
+
await _request(PURCHASE_URL, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: Object.assign({
|
|
250
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
251
|
+
'Content-Length': Buffer.byteLength(body)
|
|
252
|
+
}, headers),
|
|
253
|
+
body
|
|
254
|
+
});
|
|
255
|
+
} catch (_) { /* best effort, as in Cobalt */ }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// { baseUrl, splits: [{name, url}], cookies: {name: value} }
|
|
259
|
+
async function fetchDelivery(packageName, versionCode, headers) {
|
|
260
|
+
const raw = await _protoGet(
|
|
261
|
+
DELIVERY_URL + '?doc=' + encodeURIComponent(packageName) + '&ot=1&vc=' + versionCode,
|
|
262
|
+
headers);
|
|
263
|
+
// ResponseWrapper(1).Payload → deliveryResponse(21).appDeliveryData(2)
|
|
264
|
+
const data = _walk(raw, [1, 21, 2]);
|
|
265
|
+
if (!data) throw new Error('the Play delivery response carried no AppDeliveryData for ' + packageName);
|
|
266
|
+
|
|
267
|
+
const fields = decodeFields(data);
|
|
268
|
+
const baseUrl = _one(fields, 3);
|
|
269
|
+
if (!baseUrl || !baseUrl.length) {
|
|
270
|
+
throw new Error('Play issued no download URL for ' + packageName +
|
|
271
|
+
' — the app is unavailable for this synthetic device profile');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const splits = [];
|
|
275
|
+
for (const s of (fields.get(15) || [])) {
|
|
276
|
+
const sf = decodeFields(s);
|
|
277
|
+
const name = _one(sf, 1);
|
|
278
|
+
const url = _one(sf, 5);
|
|
279
|
+
if (!url || !url.length) continue;
|
|
280
|
+
splits.push({
|
|
281
|
+
name: name ? name.toString('utf8') : 'split-' + splits.length,
|
|
282
|
+
url: url.toString('utf8')
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Tag 4 is overloaded: cookies and OBB metadata share it. A cookie starts with
|
|
287
|
+
// the tag byte for a length-delimited field 1 (its name), which is what tells
|
|
288
|
+
// the two apart.
|
|
289
|
+
const cookies = {};
|
|
290
|
+
for (const entry of (fields.get(4) || [])) {
|
|
291
|
+
if (!entry.length || entry[0] !== 0x0a) continue;
|
|
292
|
+
const cf = decodeFields(entry);
|
|
293
|
+
const name = _one(cf, 1);
|
|
294
|
+
const value = _one(cf, 2);
|
|
295
|
+
if (!name || !name.length) continue;
|
|
296
|
+
cookies[name.toString('utf8')] = value ? value.toString('utf8') : '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { baseUrl: baseUrl.toString('utf8'), splits, cookies };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function _download(url, cookies) {
|
|
303
|
+
const headers = {};
|
|
304
|
+
const pairs = Object.keys(cookies || {}).map(k => k + '=' + cookies[k]);
|
|
305
|
+
if (pairs.length) headers.Cookie = pairs.join('; ');
|
|
306
|
+
const res = await _request(url, { headers });
|
|
307
|
+
if (res.status < 200 || res.status >= 300) {
|
|
308
|
+
throw new Error('APK download failed with HTTP ' + res.status);
|
|
309
|
+
}
|
|
310
|
+
return res.body;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Download what the Android token material needs from Play.
|
|
315
|
+
*
|
|
316
|
+
* Returns { packageName, versionCode, versionName, base, splits: [{name,data}] }
|
|
317
|
+
* with the APK bytes in hand.
|
|
318
|
+
*
|
|
319
|
+
* Only the density splits are fetched. The token's key comes from
|
|
320
|
+
* about_logo.png, which lives in one of those; the architecture and language
|
|
321
|
+
* splits are a hundred megabytes that nothing here would ever read. Pass
|
|
322
|
+
* { allSplits: true } to take everything anyway.
|
|
323
|
+
*/
|
|
324
|
+
async function downloadApk(opts) {
|
|
325
|
+
opts = opts || {};
|
|
326
|
+
const packageName = opts.packageName || PERSONAL_PACKAGE;
|
|
327
|
+
|
|
328
|
+
_whaDbg('[DBG] PLAY → dispenser');
|
|
329
|
+
const auth = await fetchAnonymousAuth();
|
|
330
|
+
const headers = buildFdfeHeaders(auth);
|
|
331
|
+
|
|
332
|
+
_whaDbg('[DBG] PLAY → details ' + packageName);
|
|
333
|
+
const version = await fetchVersion(packageName, headers);
|
|
334
|
+
if (opts.onProgress) opts.onProgress('version ' + version.versionName + ' (code ' + version.versionCode + ')');
|
|
335
|
+
|
|
336
|
+
await acquire(packageName, version.versionCode, headers);
|
|
337
|
+
|
|
338
|
+
_whaDbg('[DBG] PLAY → delivery ' + packageName + ' vc=' + version.versionCode);
|
|
339
|
+
const delivery = await fetchDelivery(packageName, version.versionCode, headers);
|
|
340
|
+
|
|
341
|
+
const wanted = opts.allSplits
|
|
342
|
+
? delivery.splits
|
|
343
|
+
: delivery.splits.filter(s => /dpi$/i.test(String(s.name).replace(/\.apk$/i, '')));
|
|
344
|
+
|
|
345
|
+
// Which densities came back is worth saying out loud. Play serves the splits
|
|
346
|
+
// that suit the device profile it was asked as, not the full set a bundle
|
|
347
|
+
// holds, so the about_logo.png this yields can be a different density from
|
|
348
|
+
// the one in a complete bundle — and a different density is a different key.
|
|
349
|
+
if (opts.onProgress) {
|
|
350
|
+
opts.onProgress('downloading base.apk and ' + wanted.length + ' density split' +
|
|
351
|
+
(wanted.length === 1 ? '' : 's') +
|
|
352
|
+
' (of ' + delivery.splits.length + ' the server offered): ' +
|
|
353
|
+
(wanted.map(s => s.name).join(', ') || 'none'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const base = await _download(delivery.baseUrl, delivery.cookies);
|
|
357
|
+
const splits = [];
|
|
358
|
+
for (const s of wanted) {
|
|
359
|
+
if (opts.onProgress) opts.onProgress(' ' + s.name);
|
|
360
|
+
splits.push({ name: s.name, data: await _download(s.url, delivery.cookies) });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
packageName,
|
|
365
|
+
versionCode: version.versionCode,
|
|
366
|
+
versionName: version.versionName,
|
|
367
|
+
base,
|
|
368
|
+
splits
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
module.exports = {
|
|
373
|
+
downloadApk,
|
|
374
|
+
fetchAnonymousAuth,
|
|
375
|
+
buildFdfeHeaders,
|
|
376
|
+
fetchVersion,
|
|
377
|
+
fetchDelivery,
|
|
378
|
+
decodeFields,
|
|
379
|
+
AURORA_DISPENSER_URL,
|
|
380
|
+
PERSONAL_PACKAGE
|
|
381
|
+
};
|
package/lib/Registration.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const tls = require('tls');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
6
9
|
const curveJs = require('curve25519-js');
|
|
7
10
|
const { v4: uuidv4 } = require('uuid');
|
|
8
11
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
12
|
+
const AndroidApk = require('./AndroidApk');
|
|
9
13
|
const attestation = require('./Attestation');
|
|
10
14
|
const { dbg: _whaDbg } = require('./logger');
|
|
11
15
|
|
|
@@ -311,7 +315,6 @@ const {
|
|
|
311
315
|
REGISTRATION_ENDPOINT,
|
|
312
316
|
REGISTRATION_PUBLIC_KEY,
|
|
313
317
|
IOS_STATIC_TOKEN,
|
|
314
|
-
ANDROID_STATIC_TOKEN,
|
|
315
318
|
IOS_VERSION_FALLBACK,
|
|
316
319
|
ANDROID_VERSION_FALLBACK,
|
|
317
320
|
IOS_USER_AGENT,
|
|
@@ -523,20 +526,162 @@ async function fetchAndroidVersion() {
|
|
|
523
526
|
|
|
524
527
|
// Return the appropriate WhatsApp version for the active device.
|
|
525
528
|
// If WA_VERSION is set in the environment, that value is always used.
|
|
529
|
+
//
|
|
530
|
+
// On Android the APK the token material came from decides it, the way Cobalt
|
|
531
|
+
// announces the versionName of the APK it downloaded. The token is signed over
|
|
532
|
+
// that build's classes.dex, so a version read from anywhere else describes a
|
|
533
|
+
// different build than the one the token proves — which the server sees as a
|
|
534
|
+
// token that does not belong to the client sending it. The live lookup stays as
|
|
535
|
+
// the fallback for when there is no material yet.
|
|
526
536
|
async function fetchWaVersion(device) {
|
|
527
537
|
if (process.env.WA_VERSION) return process.env.WA_VERSION;
|
|
528
|
-
|
|
538
|
+
if (device && device.os === 'android') {
|
|
539
|
+
const material = tryLoadAndroidMaterial();
|
|
540
|
+
if (material && material.apkVersion) return material.apkVersion;
|
|
541
|
+
return fetchAndroidVersion();
|
|
542
|
+
}
|
|
543
|
+
return fetchIosVersion();
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
// ---------- Token computation ----------
|
|
532
|
-
//
|
|
533
|
-
// The
|
|
534
|
-
//
|
|
547
|
+
//
|
|
548
|
+
// The two platforms compute this differently, and only iOS derives it from a
|
|
549
|
+
// static string:
|
|
550
|
+
//
|
|
551
|
+
// iOS MD5( staticToken + MD5hex(waVersion) + nationalNumber )
|
|
552
|
+
// Android HMAC-SHA1, keyed by material taken out of the APK, over the
|
|
553
|
+
// signing certificates, MD5(classes.dex) and the national number
|
|
554
|
+
//
|
|
555
|
+
// Android was being sent the iOS calculation with a different constant put in
|
|
556
|
+
// front of it. No such constant exists — the Android client signs the token
|
|
557
|
+
// with its own APK — and the server answered every attempt with bad_token. See
|
|
558
|
+
// AndroidApk.js for the algorithm and where the material comes from.
|
|
559
|
+
//
|
|
560
|
+
// WA_STATIC_TOKEN therefore only means anything on iOS. It cannot substitute
|
|
561
|
+
// for the Android material, so it is deliberately not consulted there.
|
|
562
|
+
|
|
563
|
+
// Where the extracted Android material is kept. Beside the sessions by default,
|
|
564
|
+
// since that is the directory the CLI already owns.
|
|
565
|
+
function androidMaterialPath() {
|
|
566
|
+
return process.env.WA_ANDROID_APK_MATERIAL ||
|
|
567
|
+
path.join(os.homedir(), '.waSession', 'android-apk-material.json');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let _androidMaterial = null;
|
|
571
|
+
|
|
572
|
+
// The material if there is any, without insisting on it. fetchWaVersion asks
|
|
573
|
+
// this way: a missing file is the token's problem to report, not the version's.
|
|
574
|
+
function tryLoadAndroidMaterial() {
|
|
575
|
+
try { return loadAndroidMaterial(); } catch (_) { return null; }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Have the Android token material ready, fetching the APK if it is not.
|
|
580
|
+
*
|
|
581
|
+
* Registering as Android cannot produce a token without material read out of a
|
|
582
|
+
* WhatsApp APK, and that material goes stale on every WhatsApp release. Asking
|
|
583
|
+
* the caller to go and find an APK first turns one command into a chore, so a
|
|
584
|
+
* registration that finds none fetches one from Play itself and carries on.
|
|
585
|
+
*
|
|
586
|
+
* Only a *missing* file is answered this way. An APK that turns out to be a
|
|
587
|
+
* repack is refused as it was before — downloading over somebody's deliberate
|
|
588
|
+
* choice of APK would be the wrong way to be helpful.
|
|
589
|
+
*
|
|
590
|
+
* A no-op on iOS, and WA_NO_APK_DOWNLOAD=1 turns it off for anyone who would
|
|
591
|
+
* rather a hundred megabytes were never fetched without being asked for.
|
|
592
|
+
*/
|
|
593
|
+
async function ensureAndroidMaterial(opts) {
|
|
594
|
+
opts = opts || {};
|
|
595
|
+
const device = getDeviceConfig();
|
|
596
|
+
if (device.os !== 'android') return null;
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
return loadAndroidMaterial();
|
|
600
|
+
} catch (err) {
|
|
601
|
+
if (err.code !== 'NO_APK_MATERIAL') throw err;
|
|
602
|
+
if (process.env.WA_NO_APK_DOWNLOAD === '1') throw err;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const say = opts.onProgress || (m => _whaDbg('[DBG] APK ' + m));
|
|
606
|
+
const AndroidApk = require('./AndroidApk');
|
|
607
|
+
const PlayStore = require('./PlayStore');
|
|
608
|
+
|
|
609
|
+
say('no Android token material yet — fetching the WhatsApp APK from Google Play');
|
|
610
|
+
let apk;
|
|
611
|
+
try {
|
|
612
|
+
apk = await PlayStore.downloadApk({ onProgress: m => say(' ' + m) });
|
|
613
|
+
} catch (err) {
|
|
614
|
+
throw new Error('could not fetch the WhatsApp APK automatically: ' + err.message +
|
|
615
|
+
'\nRead it out of an APK you have instead:\n' +
|
|
616
|
+
' wa apk-material <base.apk> [split.apk ...]');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const material = AndroidApk.extractMaterial(apk.base, apk.splits);
|
|
620
|
+
if (!material.apkVersion) material.apkVersion = apk.versionName;
|
|
621
|
+
if (!material.apkVersionCode) material.apkVersionCode = apk.versionCode;
|
|
622
|
+
|
|
623
|
+
const file = androidMaterialPath();
|
|
624
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
625
|
+
fs.writeFileSync(file, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
|
|
626
|
+
say(' token material written to ' + file);
|
|
627
|
+
|
|
628
|
+
// Re-read it rather than using what is in hand, so the downloaded APK goes
|
|
629
|
+
// through the same signature check as one that was already on disk.
|
|
630
|
+
_androidMaterial = null;
|
|
631
|
+
return loadAndroidMaterial();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function loadAndroidMaterial() {
|
|
635
|
+
if (_androidMaterial) return _androidMaterial;
|
|
636
|
+
const file = androidMaterialPath();
|
|
637
|
+
let raw;
|
|
638
|
+
try {
|
|
639
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
640
|
+
} catch (_) {
|
|
641
|
+
const err = new Error('registering as Android needs the token material from a WhatsApp APK, ' +
|
|
642
|
+
'and there is none at ' + file + '. Fetch it with:\n' +
|
|
643
|
+
' wa apk-material --download\n' +
|
|
644
|
+
'or read it out of an APK you already have:\n' +
|
|
645
|
+
' wa apk-material <base.apk> [split.apk ...]\n' +
|
|
646
|
+
'Registering as iOS needs none of this.');
|
|
647
|
+
// Told apart from a refused repack, which must never be answered by
|
|
648
|
+
// downloading over it.
|
|
649
|
+
err.code = 'NO_APK_MATERIAL';
|
|
650
|
+
throw err;
|
|
651
|
+
}
|
|
652
|
+
const material = AndroidApk.materialFromJson(JSON.parse(raw));
|
|
653
|
+
|
|
654
|
+
// Refuse a repack before anything is sent.
|
|
655
|
+
//
|
|
656
|
+
// The token is an HMAC over the APK's signing certificates. An APK a mirror
|
|
657
|
+
// re-signed — commonly with the AOSP test key, whose private half ships in the
|
|
658
|
+
// Android sources — produces a well-formed token that belongs to nobody, and
|
|
659
|
+
// the server answers bad_token, exactly as it does when there is no material
|
|
660
|
+
// at all. Left to run, that spends /code attempts against a real phone number
|
|
661
|
+
// on a request that cannot succeed, and attempts are what gets a number rate
|
|
662
|
+
// limited. Checking the subject costs nothing and is the difference between a
|
|
663
|
+
// refusal here and a number burned.
|
|
664
|
+
const signer = AndroidApk.describeCertificate(material.certificates[0]);
|
|
665
|
+
if (signer && !AndroidApk.looksLikeWhatsAppCertificate(signer) &&
|
|
666
|
+
process.env.WA_ALLOW_FOREIGN_APK !== '1') {
|
|
667
|
+
throw new Error('the APK this token material came from was not signed by WhatsApp — ' +
|
|
668
|
+
'it is signed by "' + signer.subject.replace(/\n/g, ', ') + '". Re-signing replaces ' +
|
|
669
|
+
'the certificate the token is built from, so every attempt would come back as ' +
|
|
670
|
+
'bad_token. Take the APK from a phone that has WhatsApp installed:\n' +
|
|
671
|
+
' pm path com.whatsapp\n' +
|
|
672
|
+
'then re-run wa apk-material with it. Set WA_ALLOW_FOREIGN_APK=1 to send it anyway.');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_androidMaterial = material;
|
|
676
|
+
return _androidMaterial;
|
|
677
|
+
}
|
|
535
678
|
|
|
536
679
|
function computeToken(waVersion, national) {
|
|
537
|
-
const device
|
|
538
|
-
|
|
539
|
-
|
|
680
|
+
const device = getDeviceConfig();
|
|
681
|
+
if (device.os === 'android') {
|
|
682
|
+
return AndroidApk.computeToken(loadAndroidMaterial(), national);
|
|
683
|
+
}
|
|
684
|
+
const staticToken = process.env.WA_STATIC_TOKEN || IOS_STATIC_TOKEN;
|
|
540
685
|
const versionHashHex = md5Bytes(waVersion).toString('hex');
|
|
541
686
|
return md5Hex(staticToken + versionHashHex + national);
|
|
542
687
|
}
|
|
@@ -1186,6 +1331,9 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1186
1331
|
async function requestSmsCode(store, method, opts) {
|
|
1187
1332
|
method = method || 'sms';
|
|
1188
1333
|
opts = opts || {};
|
|
1334
|
+
// Before the version is read, since on Android the version comes out of the
|
|
1335
|
+
// APK this fetches.
|
|
1336
|
+
await ensureAndroidMaterial(opts);
|
|
1189
1337
|
const _device = getDeviceConfig();
|
|
1190
1338
|
const waVersion = await fetchWaVersion(_device);
|
|
1191
1339
|
store.version = waVersion;
|
|
@@ -1305,6 +1453,9 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1305
1453
|
|
|
1306
1454
|
async function verifyCode(store, code, opts) {
|
|
1307
1455
|
opts = opts || {};
|
|
1456
|
+
// Normally already there from the code request, but a confirmation can be run
|
|
1457
|
+
// from a fresh shell that never made one.
|
|
1458
|
+
await ensureAndroidMaterial(opts);
|
|
1308
1459
|
const _device = getDeviceConfig();
|
|
1309
1460
|
const waVersion = await fetchWaVersion(_device);
|
|
1310
1461
|
store.version = waVersion;
|
|
@@ -1399,6 +1550,9 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
|
|
|
1399
1550
|
// The HTTP reader, exposed for tests. Not part of the public API.
|
|
1400
1551
|
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
1401
1552
|
|
|
1553
|
+
// Token computation, exposed for tests. Not part of the public API.
|
|
1554
|
+
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial };
|
|
1555
|
+
|
|
1402
1556
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1403
1557
|
module.exports._verify = {
|
|
1404
1558
|
hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
|
package/lib/constants.js
CHANGED
|
@@ -31,11 +31,15 @@ const WEB_VERSION_FALLBACK = [2, 3000, 1035194821];
|
|
|
31
31
|
// the name the account owner sees in the linked-devices list on their phone.
|
|
32
32
|
const WEB_BROWSER = ['Ubuntu', 'Chrome', '120.0.0.0'];
|
|
33
33
|
|
|
34
|
-
// Static tokens used in
|
|
34
|
+
// Static tokens used in the iOS registration token computation.
|
|
35
35
|
// token = MD5( staticToken + MD5hex(waVersion) + nationalNumber )
|
|
36
|
+
//
|
|
37
|
+
// iOS only. Android has no static token at all — it signs the token with
|
|
38
|
+
// material out of its own APK (see AndroidApk.js), and the value that used to
|
|
39
|
+
// sit here as ANDROID_STATIC_TOKEN was this same iOS calculation with an
|
|
40
|
+
// invented constant in front of it, which the server refused as bad_token.
|
|
36
41
|
const IOS_STATIC_TOKEN = '0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM';
|
|
37
42
|
const IOS_BUSINESS_STATIC_TOKEN = 'USUDuDYDeQhY4RF2fCSp5m3F6kJ1M2J8wS7bbNA2';
|
|
38
|
-
const ANDROID_STATIC_TOKEN = 'Y29Cs6AVNR2bj5PBeKSYFd1nAKuvNQ3h';
|
|
39
43
|
|
|
40
44
|
// WhatsApp version fallbacks — used when live fetch fails.
|
|
41
45
|
const IOS_VERSION_FALLBACK = '2.26.9.75';
|
|
@@ -228,7 +232,6 @@ module.exports = {
|
|
|
228
232
|
WEB_BROWSER,
|
|
229
233
|
IOS_STATIC_TOKEN,
|
|
230
234
|
IOS_BUSINESS_STATIC_TOKEN,
|
|
231
|
-
ANDROID_STATIC_TOKEN,
|
|
232
235
|
IOS_VERSION_FALLBACK,
|
|
233
236
|
ANDROID_VERSION_FALLBACK,
|
|
234
237
|
IOS_USER_AGENT,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Aurora-style device profile posted to the anonymous token dispenser.
|
|
2
|
+
// Copied verbatim from Cobalt's sdk-gphone64-x86_64.json — the dispenser
|
|
3
|
+
// mints an account shaped for this device, and the Play catalogue decides
|
|
4
|
+
// what it is allowed to download from it.
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
"HasHardKeyboard": "true",
|
|
10
|
+
"Build.HARDWARE": "ranchu",
|
|
11
|
+
"Build.BRAND": "google",
|
|
12
|
+
"Build.VERSION.SDK_INT": "33",
|
|
13
|
+
"Roaming": "mobile-notroaming",
|
|
14
|
+
"Build.MODEL": "sdk_gphone64_x86_64",
|
|
15
|
+
"Vending.versionString": "51.0.19-31 [0] [PR] 897988648",
|
|
16
|
+
"Vending.version": "85101930",
|
|
17
|
+
"CellOperator": "310",
|
|
18
|
+
"Build.FINGERPRINT": "google/sdk_gphone64_x86_64/emu64x:13/TE1A.240213.009/12342917:user/release-keys",
|
|
19
|
+
"Build.DEVICE": "emu64x",
|
|
20
|
+
"Screen.Height": "3120",
|
|
21
|
+
"SimOperator": "260",
|
|
22
|
+
"ScreenLayout": "2",
|
|
23
|
+
"SharedLibraries": "android.ext.shared,android.hidl.base-V1.0-java,android.hidl.manager-V1.0-java,android.net.ipsec.ike,android.test.base,android.test.mock,android.test.runner,androidx.window.extensions,androidx.window.sidecar,com.android.cts.ctsshim.shared_library,com.android.future.usb.accessory,com.android.location.provider,com.android.media.remotedisplay,com.android.mediadrm.signer,com.android.nfc_extras,com.google.android.dialer.support,com.google.android.gms,com.google.android.trichromelibrary,javax.obex,org.apache.http.legacy",
|
|
24
|
+
"Features": "android.hardware.audio.output,android.hardware.bluetooth,android.hardware.bluetooth_le,android.hardware.camera,android.hardware.camera.any,android.hardware.camera.ar,android.hardware.camera.autofocus,android.hardware.camera.capability.manual_post_processing,android.hardware.camera.capability.manual_sensor,android.hardware.camera.capability.raw,android.hardware.camera.concurrent,android.hardware.camera.flash,android.hardware.camera.front,android.hardware.camera.level.full,android.hardware.faketouch,android.hardware.fingerprint,android.hardware.hardware_keystore,android.hardware.identity_credential,android.hardware.keystore.app_attest_key,android.hardware.location,android.hardware.location.gps,android.hardware.location.network,android.hardware.microphone,android.hardware.ram.normal,android.hardware.screen.landscape,android.hardware.screen.portrait,android.hardware.security.model.compatible,android.hardware.sensor.accelerometer,android.hardware.sensor.ambient_temperature,android.hardware.sensor.barometer,android.hardware.sensor.compass,android.hardware.sensor.gyroscope,android.hardware.sensor.hinge_angle,android.hardware.sensor.light,android.hardware.sensor.proximity,android.hardware.sensor.relative_humidity,android.hardware.telephony,android.hardware.telephony.data,android.hardware.telephony.gsm,android.hardware.telephony.ims,android.hardware.telephony.radio.access,android.hardware.telephony.subscription,android.hardware.touchscreen,android.hardware.touchscreen.multitouch,android.hardware.touchscreen.multitouch.distinct,android.hardware.touchscreen.multitouch.jazzhand,android.hardware.vulkan.compute,android.hardware.vulkan.level,android.hardware.vulkan.version,android.hardware.wifi,android.hardware.wifi.direct,android.hardware.wifi.passpoint,android.software.activities_on_secondary_displays,android.software.adoptable_storage,android.software.app_enumeration,android.software.app_widgets,android.software.autofill,android.software.backup,android.software.cant_save_state,android.software.companion_device_setup,android.software.controls,android.software.cts,android.software.device_admin,android.software.erofs,android.software.file_based_encryption,android.software.home_screen,android.software.incremental_delivery,android.software.input_methods,android.software.ipsec_tunnels,android.software.live_wallpaper,android.software.managed_users,android.software.midi,android.software.opengles.deqp.level,android.software.picture_in_picture,android.software.print,android.software.secure_lock_screen,android.software.securely_removes_users,android.software.telecom,android.software.verified_boot,android.software.voice_recognizers,android.software.vulkan.deqp.level,android.software.webview,com.google.android.apps.dialer.SUPPORTED,com.google.android.feature.EXCHANGE_6_2,com.google.android.feature.GOOGLE_BUILD,com.google.android.feature.GOOGLE_EXPERIENCE,com.google.android.feature.WELLBEING",
|
|
25
|
+
"GSF.version": "33",
|
|
26
|
+
"UserReadableName": "Android Emulator x86_64 (API 33)",
|
|
27
|
+
"Build.RADIO": "1.0.0.0",
|
|
28
|
+
"Client": "android-google",
|
|
29
|
+
"Locales": "af,af_ZA,am,am_ET,ar,ar_EG,ar_XB,as,az,be,bg,bg_BG,bn,bs,ca,ca_ES,cs,cs_CZ,da,da_DK,de,de_DE,el,el_GR,en,en_AU,en_CA,en_GB,en_IN,en_US,en_XA,es,es_ES,es_US,et,eu,fa,fa_IR,fi,fi_FI,fr,fr_CA,fr_FR,gl,gu,hi,hi_IN,hr,hr_HR,hu,hu_HU,hy,in,in_ID,is,it,it_IT,iw,iw_IL,ja,ja_JP,ka,kk,km,kn,ko,ko_KR,ky,lo,lt,lt_LT,lv,lv_LV,mk,ml,mn,mr,ms,ms_MY,my,nb,nb_NO,ne,nl,nl_NL,or,pa,pl,pl_PL,pt,pt_BR,pt_PT,ro,ro_RO,ru,ru_RU,si,sk,sk_SK,sl,sl_SI,sq,sr,sr_RS,sv,sv_SE,sw,sw_TZ,ta,te,th,th_TH,tl,tl_PH,tr,tr_TR,uk,uk_UA,ur,uz,vi,vi_VN,zh_CN,zh_HK,zh_TW,zu,zu_ZA",
|
|
30
|
+
"Screen.Density": "560",
|
|
31
|
+
"Platforms": "x86_64",
|
|
32
|
+
"Build.PRODUCT": "sdk_gphone64_x86_64",
|
|
33
|
+
"Navigation": "1",
|
|
34
|
+
"Keyboard": "2",
|
|
35
|
+
"Build.ID": "TE1A.240213.009",
|
|
36
|
+
"TimeZone": "UTC+1",
|
|
37
|
+
"Screen.Width": "1440",
|
|
38
|
+
"GL.Extensions": "GL_APPLE_texture_format_BGRA8888,GL_EXT_color_buffer_float,GL_EXT_color_buffer_half_float,GL_EXT_debug_marker,GL_EXT_robustness,GL_EXT_texture_format_BGRA8888,GL_KHR_texture_compression_astc_ldr,GL_OES_EGL_image,GL_OES_EGL_image_external,GL_OES_EGL_image_external_essl3,GL_OES_EGL_sync,GL_OES_compressed_ETC1_RGB8_texture,GL_OES_compressed_paletted_texture,GL_OES_depth24,GL_OES_depth32,GL_OES_depth_texture,GL_OES_element_index_uint,GL_OES_packed_depth_stencil,GL_OES_rgb8_rgba8,GL_OES_standard_derivatives,GL_OES_texture_float,GL_OES_texture_float_linear,GL_OES_texture_half_float,GL_OES_texture_half_float_linear,GL_OES_texture_npot,GL_OES_vertex_array_object,GL_OES_vertex_half_float",
|
|
39
|
+
"HasFiveWayNavigation": "false",
|
|
40
|
+
"TouchScreen": "3",
|
|
41
|
+
"GL.Version": "196608",
|
|
42
|
+
"Build.VERSION.RELEASE": "13",
|
|
43
|
+
"Build.BOOTLOADER": "unknown",
|
|
44
|
+
"Build.MANUFACTURER": "Google"
|
|
45
|
+
};
|