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.
@@ -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
+ };
@@ -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
- return (device && device.os === 'android') ? fetchAndroidVersion() : fetchIosVersion();
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
- // token = MD5( staticToken + MD5hex(waVersion) + nationalNumber )
533
- // The static token depends on the platform (iOS vs Android).
534
- // Override with WA_STATIC_TOKEN in .env if the bundled token becomes stale.
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 = getDeviceConfig();
538
- const staticToken = process.env.WA_STATIC_TOKEN
539
- || (device.os === 'android' ? ANDROID_STATIC_TOKEN : IOS_STATIC_TOKEN);
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 WhatsApp registration token computation.
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
+ };