whalibmob 5.12.11 → 5.12.14
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 +14 -1
- package/README.md +84 -7
- package/cli.js +61 -14
- package/lib/AndroidApk.js +58 -9
- package/lib/PlayStore.js +381 -0
- package/lib/Registration.js +116 -18
- package/lib/playstore-device.js +45 -0
- package/package.json +2 -2
package/.env.example
CHANGED
|
@@ -66,14 +66,27 @@
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
# ─── Android registration token material ─────────────────────────────────────
|
|
69
|
-
# Registering with WA_OS=android needs material read out of a WhatsApp APK
|
|
69
|
+
# Registering with WA_OS=android needs material read out of a WhatsApp APK. The
|
|
70
|
+
# first registration fetches the APK from Google Play and does this on its own —
|
|
71
|
+
# nothing to run first. To do it by hand, from a Play download or an APK you
|
|
72
|
+
# already have:
|
|
70
73
|
#
|
|
74
|
+
# wa apk-material --download
|
|
71
75
|
# wa apk-material base.apk [split_config.xxhdpi.apk ...]
|
|
72
76
|
#
|
|
73
77
|
# It is written to <session dir>/android-apk-material.json by default. Set this
|
|
74
78
|
# to keep it somewhere else. Registering as iOS needs none of it.
|
|
75
79
|
# WA_ANDROID_APK_MATERIAL=/path/to/android-apk-material.json
|
|
76
80
|
|
|
81
|
+
# Never fetch the APK automatically. Registration then fails with instructions
|
|
82
|
+
# instead of pulling a hundred megabytes unasked.
|
|
83
|
+
# WA_NO_APK_DOWNLOAD=1
|
|
84
|
+
|
|
85
|
+
# Register with material from an APK that is not signed by WhatsApp. Refused by
|
|
86
|
+
# default, because a re-signed APK yields a token that belongs to nobody and
|
|
87
|
+
# spends code requests against your number for nothing.
|
|
88
|
+
# WA_ALLOW_FOREIGN_APK=1
|
|
89
|
+
|
|
77
90
|
|
|
78
91
|
# ─── Proxy ───────────────────────────────────────────────────────────────────
|
|
79
92
|
# Route registration traffic through a SOCKS proxy. Requires the `socks`
|
package/README.md
CHANGED
|
@@ -121,6 +121,9 @@ npm install -g whalibmob
|
|
|
121
121
|
- [Connecting Account](#connecting-account)
|
|
122
122
|
- [Register a New Number](#register-a-new-number)
|
|
123
123
|
- [Registering as Android](#registering-as-android)
|
|
124
|
+
- [Why an APK is involved at all](#why-an-apk-is-involved-at-all)
|
|
125
|
+
- [Fetching the APK on its own](#fetching-the-apk-on-its-own)
|
|
126
|
+
- [Reading it out of an APK you already have](#reading-it-out-of-an-apk-you-already-have)
|
|
124
127
|
- [Device Attestation with Frida (optional)](#device-attestation-with-frida-optional)
|
|
125
128
|
- [Connect](#connect)
|
|
126
129
|
- [Linking to an Existing Account (Pairing Code)](#linking-to-an-existing-account-pairing-code)
|
|
@@ -274,10 +277,6 @@ wa version
|
|
|
274
277
|
|
|
275
278
|
Registration is a one-time process. You need a phone number that can receive an SMS or voice call. **Use a dedicated number** — do not use a number already active on a real WhatsApp device.
|
|
276
279
|
|
|
277
|
-
> [!IMPORTANT]
|
|
278
|
-
> Registering as **Android** (`WA_OS=android`) needs one extra step first — see
|
|
279
|
-
> [Registering as Android](#registering-as-android). Registering as iOS, the default, does not.
|
|
280
|
-
|
|
281
280
|
**Step 1 — request a verification code**
|
|
282
281
|
|
|
283
282
|
```sh
|
|
@@ -291,6 +290,16 @@ wa registration --request-code 919634847671 --method voice
|
|
|
291
290
|
wa registration --request-code 919634847671 --method wa_old
|
|
292
291
|
```
|
|
293
292
|
|
|
293
|
+
**Registering as Android** is the same command with the platform named. Nothing
|
|
294
|
+
else to prepare — see [what it does behind that one command](#registering-as-android):
|
|
295
|
+
|
|
296
|
+
```sh
|
|
297
|
+
WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Set `WA_OS=android` in a `.env` file instead and the plain command is enough.
|
|
301
|
+
`--debug` prints every request and reply, which is worth having the first time.
|
|
302
|
+
|
|
294
303
|
The CLI sends the code request, prints the result, and then **stays open** in the interactive shell. You will see:
|
|
295
304
|
|
|
296
305
|
```
|
|
@@ -1551,6 +1560,34 @@ Both are optional and both keep working when omitted — you get an error naming
|
|
|
1551
1560
|
|
|
1552
1561
|
### Registering as Android
|
|
1553
1562
|
|
|
1563
|
+
**There is nothing to do first.** Name the platform and register:
|
|
1564
|
+
|
|
1565
|
+
```sh
|
|
1566
|
+
WA_OS=android WA_DEVICE=samsung-s24-ultra wa registration --request-code 919634847671 --debug
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
The first Android registration finds no token material, fetches the WhatsApp APK
|
|
1570
|
+
from Google Play, reads what it needs out of it, checks that WhatsApp really
|
|
1571
|
+
signed it, and carries on to the code request — one command, no APK to find:
|
|
1572
|
+
|
|
1573
|
+
```
|
|
1574
|
+
no Android token material yet — fetching the WhatsApp APK from Google Play
|
|
1575
|
+
version 2.26.30.3 (code 263000302)
|
|
1576
|
+
downloading base.apk and 1 density split (of 24 the server offered): config.xxxhdpi
|
|
1577
|
+
token material written to /home/you/.waSession/android-apk-material.json
|
|
1578
|
+
requesting sms code for +919634847671...
|
|
1579
|
+
status sent
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
It happens once. Every later registration reads the file. `WA_NO_APK_DOWNLOAD=1`
|
|
1583
|
+
turns the fetch off if you would rather it never pulled a hundred megabytes
|
|
1584
|
+
unasked, and `wa apk-material` below does the same job by hand.
|
|
1585
|
+
|
|
1586
|
+
The rest of this section is what happens behind that one command, and how to
|
|
1587
|
+
drive each part yourself.
|
|
1588
|
+
|
|
1589
|
+
#### Why an APK is involved at all
|
|
1590
|
+
|
|
1554
1591
|
The registration token is computed differently on each platform, and only iOS
|
|
1555
1592
|
derives it from a constant. The Android client signs it with material out of its
|
|
1556
1593
|
own APK:
|
|
@@ -1568,7 +1605,34 @@ the iOS-shaped token as Android is answered with `{"reason":"bad_token"}`, and n
|
|
|
1568
1605
|
value in `WA_STATIC_TOKEN` changes that — the constant is not what is wrong, the
|
|
1569
1606
|
formula is. `WA_STATIC_TOKEN` applies to iOS only.
|
|
1570
1607
|
|
|
1571
|
-
|
|
1608
|
+
#### Fetching the APK on its own
|
|
1609
|
+
|
|
1610
|
+
This runs by itself on a registration that finds no material. To do it separately
|
|
1611
|
+
— to refresh after a WhatsApp release, or just to see what it picks up:
|
|
1612
|
+
|
|
1613
|
+
```sh
|
|
1614
|
+
wa apk-material --download
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
It is the route Cobalt takes: an anonymous Play Store token from the Aurora OSS
|
|
1618
|
+
dispenser, then Google's own `/fdfe` catalogue and delivery endpoints, then the
|
|
1619
|
+
signed CDN URLs. **No Google account of yours is involved.** Only the density
|
|
1620
|
+
splits are downloaded — the token's key comes from `about_logo.png`, which lives
|
|
1621
|
+
in one of those, and the architecture and language splits would be a hundred
|
|
1622
|
+
megabytes nothing here reads.
|
|
1623
|
+
|
|
1624
|
+
Two caveats. The dispenser is a free third-party service: when it is down or rate
|
|
1625
|
+
limiting, this fails and an APK from a phone still works. And downloading from
|
|
1626
|
+
Play this way is against Play's terms of service, which is your call to make.
|
|
1627
|
+
|
|
1628
|
+
Play serves the splits that suit the device profile it is asked as, not the full
|
|
1629
|
+
set a bundle holds, so the density this yields can differ from the one in a
|
|
1630
|
+
complete bundle — and a different density is a different key. The `about_logo`
|
|
1631
|
+
line in the output names which one was used.
|
|
1632
|
+
|
|
1633
|
+
#### Reading it out of an APK you already have
|
|
1634
|
+
|
|
1635
|
+
Pull one off any phone or emulator that has WhatsApp installed:
|
|
1572
1636
|
|
|
1573
1637
|
```sh
|
|
1574
1638
|
adb shell pm path com.whatsapp
|
|
@@ -1593,10 +1657,21 @@ reading base.apk...
|
|
|
1593
1657
|
package com.whatsapp
|
|
1594
1658
|
version 2.26.25.80 (code 260908001)
|
|
1595
1659
|
certificates 1
|
|
1660
|
+
signed by C=US, ST=California, L=Santa Clara, O=WhatsApp Inc., …
|
|
1661
|
+
sha256 AD:AD:64:31:29:8F:64:2B:…
|
|
1596
1662
|
classes.dex md5 5c71f02aaad331e16e436bfa83ea3c5b
|
|
1597
1663
|
written to /home/you/.waSession/android-apk-material.json
|
|
1598
1664
|
```
|
|
1599
1665
|
|
|
1666
|
+
**Watch the `signed by` line.** The token is an HMAC over the signing
|
|
1667
|
+
certificates, so it is only the token the server expects when those certificates
|
|
1668
|
+
are WhatsApp's own. Mirrors re-sign the APKs they host, commonly with the AOSP
|
|
1669
|
+
test key whose private half ships in the Android sources — a re-signed APK yields
|
|
1670
|
+
a token that is well-formed and belongs to nobody. Anything other than WhatsApp
|
|
1671
|
+
in that subject is called out here as a warning, and registration refuses to
|
|
1672
|
+
start on it rather than spending code requests against your number on something
|
|
1673
|
+
that cannot succeed. `WA_ALLOW_FOREIGN_APK=1` sends it anyway.
|
|
1674
|
+
|
|
1600
1675
|
Only the derived pieces are kept — the APK is never needed again. Registration
|
|
1601
1676
|
picks the file up on its own from then on. `--out <file>` writes it elsewhere,
|
|
1602
1677
|
and `WA_ANDROID_APK_MATERIAL` points at it if you keep it somewhere else.
|
|
@@ -1608,8 +1683,10 @@ announcing any other version describes a build the token does not belong to.
|
|
|
1608
1683
|
`WA_VERSION` still overrides everything if you need it to; on a manifest with no
|
|
1609
1684
|
`versionName`, pass `--version <x.y.z.w>`.
|
|
1610
1685
|
|
|
1611
|
-
**
|
|
1612
|
-
|
|
1686
|
+
**Refresh it on a new WhatsApp release.** `classes.dex` changes every release and
|
|
1687
|
+
its MD5 is signed into the token, so material from an older build stops matching
|
|
1688
|
+
the version being announced. `wa apk-material --download` re-reads the current
|
|
1689
|
+
one; deleting the file and registering again does the same thing on its own.
|
|
1613
1690
|
|
|
1614
1691
|
Programmatically:
|
|
1615
1692
|
|
package/cli.js
CHANGED
|
@@ -252,9 +252,26 @@ function enableWireTrace() {
|
|
|
252
252
|
};
|
|
253
253
|
req.on('response', (res) => {
|
|
254
254
|
trace(`\n${C.http}${stamp()} ◀── HTTP ${res.statusCode} from ${host}${C.off}`);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
// Registration answers in JSON, so the body was printed as text. An APK
|
|
256
|
+
// download answers in tens of megabytes of binary, and printing that as
|
|
257
|
+
// text emptied a terminal full of control characters. Keep a bounded head
|
|
258
|
+
// of it, decide from those bytes whether it is text at all, and say what
|
|
259
|
+
// it was rather than showing it when it is not.
|
|
260
|
+
const head = [];
|
|
261
|
+
let seen = 0, total = 0;
|
|
262
|
+
res.on('data', (d) => {
|
|
263
|
+
total += d.length;
|
|
264
|
+
if (seen < 4096) { head.push(d); seen += d.length; }
|
|
265
|
+
});
|
|
266
|
+
res.on('end', () => {
|
|
267
|
+
if (!total) return;
|
|
268
|
+
const buf = Buffer.concat(head).slice(0, 4096);
|
|
269
|
+
if (isPrintable(buf)) {
|
|
270
|
+
trace(` ${C.dim}${buf.toString('utf8')}${total > buf.length ? ' …' : ''}${C.off}`);
|
|
271
|
+
} else {
|
|
272
|
+
trace(` ${C.dim}[${total} bytes, binary]${C.off}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
258
275
|
});
|
|
259
276
|
return req;
|
|
260
277
|
};
|
|
@@ -2068,7 +2085,7 @@ async function handleLine(line) {
|
|
|
2068
2085
|
}
|
|
2069
2086
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2070
2087
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2071
|
-
const codeOpts = method === 'email' ? { email: emailAddr } : {};
|
|
2088
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {}, { onProgress: out });
|
|
2072
2089
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2073
2090
|
store.codePending = true;
|
|
2074
2091
|
saveStore(store, sessFile);
|
|
@@ -2083,7 +2100,7 @@ async function handleLine(line) {
|
|
|
2083
2100
|
const file = path.join(_sessDir, `${ph}.json`);
|
|
2084
2101
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2085
2102
|
out('verifying...');
|
|
2086
|
-
const r = await verifyCode(store, code, registrationPrompts());
|
|
2103
|
+
const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
|
|
2087
2104
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2088
2105
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2089
2106
|
const finalStore = r.store || store;
|
|
@@ -2329,6 +2346,7 @@ usage:
|
|
|
2329
2346
|
wa registration --register <phone> --code <code>
|
|
2330
2347
|
wa registration --check <phone>
|
|
2331
2348
|
wa apk-material <base.apk> [split.apk ...] read the Android token material
|
|
2349
|
+
wa apk-material --download fetch that APK from Google Play
|
|
2332
2350
|
wa version
|
|
2333
2351
|
|
|
2334
2352
|
options:
|
|
@@ -2445,8 +2463,9 @@ async function main() {
|
|
|
2445
2463
|
if (cmd === 'apk-material') {
|
|
2446
2464
|
// parseArgs puts the first non-flag argument in sub, the rest in pos.
|
|
2447
2465
|
const apks = [sub, ...pos].filter(Boolean);
|
|
2448
|
-
if (!apks.length) {
|
|
2466
|
+
if (!apks.length && !flags.download) {
|
|
2449
2467
|
fail('usage: wa apk-material <base.apk> [split.apk ...]');
|
|
2468
|
+
out(' wa apk-material --download fetch the APK from Google Play instead');
|
|
2450
2469
|
process.exit(1);
|
|
2451
2470
|
}
|
|
2452
2471
|
const outFile = flags.out ||
|
|
@@ -2454,12 +2473,26 @@ async function main() {
|
|
|
2454
2473
|
path.join(_sessDir, 'android-apk-material.json');
|
|
2455
2474
|
try {
|
|
2456
2475
|
const AndroidApk = require('./lib/AndroidApk');
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2476
|
+
let material;
|
|
2477
|
+
if (flags.download) {
|
|
2478
|
+
const PlayStore = require('./lib/PlayStore');
|
|
2479
|
+
out('fetching ' + (flags.business ? 'com.whatsapp.w4b' : 'com.whatsapp') + ' from Google Play...');
|
|
2480
|
+
const apk = await PlayStore.downloadApk({
|
|
2481
|
+
packageName: flags.business ? 'com.whatsapp.w4b' : 'com.whatsapp',
|
|
2482
|
+
onProgress: (m) => out(' ' + m)
|
|
2483
|
+
});
|
|
2484
|
+
material = AndroidApk.extractMaterial(apk.base, apk.splits);
|
|
2485
|
+
// The catalogue's version is the authority when the manifest has none.
|
|
2486
|
+
if (!material.apkVersion) material.apkVersion = apk.versionName;
|
|
2487
|
+
if (!material.apkVersionCode) material.apkVersionCode = apk.versionCode;
|
|
2488
|
+
} else {
|
|
2489
|
+
const [basePath, ...splitPaths] = apks;
|
|
2490
|
+
out('reading ' + basePath + '...');
|
|
2491
|
+
material = AndroidApk.extractMaterial(
|
|
2492
|
+
fs.readFileSync(basePath),
|
|
2493
|
+
splitPaths.map(p => ({ name: path.basename(p), data: fs.readFileSync(p) }))
|
|
2494
|
+
);
|
|
2495
|
+
}
|
|
2463
2496
|
if (flags.version) material.apkVersion = String(flags.version);
|
|
2464
2497
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2465
2498
|
fs.writeFileSync(outFile, JSON.stringify(AndroidApk.materialToJson(material), null, 2));
|
|
@@ -2467,10 +2500,24 @@ async function main() {
|
|
|
2467
2500
|
kv('version', material.apkVersion
|
|
2468
2501
|
? material.apkVersion + (material.apkVersionCode ? ' (code ' + material.apkVersionCode + ')' : '')
|
|
2469
2502
|
: '(not in the manifest — pass --version <x.y.z.w>)');
|
|
2503
|
+
if (material.aboutLogoFrom) kv('about_logo', material.aboutLogoFrom);
|
|
2470
2504
|
kv('certificates', String(material.certificates.length));
|
|
2505
|
+
const signer = AndroidApk.describeCertificate(material.certificates[0]);
|
|
2506
|
+
if (signer) {
|
|
2507
|
+
kv('signed by', signer.subject);
|
|
2508
|
+
kv('sha256', signer.fingerprint256);
|
|
2509
|
+
}
|
|
2471
2510
|
kv('classes.dex md5', material.classesDexMd5.toString('hex'));
|
|
2472
2511
|
kv('written to', outFile);
|
|
2473
2512
|
out('');
|
|
2513
|
+
if (signer && !AndroidApk.looksLikeWhatsAppCertificate(signer)) {
|
|
2514
|
+
warn('this APK is not signed by WhatsApp — the subject above is somebody else.');
|
|
2515
|
+
out(' Mirrors re-sign the APKs they host, and re-signing replaces the certificate');
|
|
2516
|
+
out(' the token is built from. The token will come out well-formed and belong to');
|
|
2517
|
+
out(' nobody, which the server answers with bad_token. Use the APK installed on a');
|
|
2518
|
+
out(' phone instead: pm path com.whatsapp');
|
|
2519
|
+
out('');
|
|
2520
|
+
}
|
|
2474
2521
|
if (material.apkVersion) {
|
|
2475
2522
|
out(' Registration will announce ' + material.apkVersion + ' from now on, because the');
|
|
2476
2523
|
out(' token is signed over this APK — the live Play Store version would name a');
|
|
@@ -2541,7 +2588,7 @@ async function main() {
|
|
|
2541
2588
|
const methodLabel = method === 'email' ? ('email → ' + emailAddr) : method;
|
|
2542
2589
|
out('requesting ' + methodLabel + ' code for +' + ph + '...');
|
|
2543
2590
|
try {
|
|
2544
|
-
const codeOpts = method === 'email' ? { email: emailAddr } : {};
|
|
2591
|
+
const codeOpts = Object.assign(method === 'email' ? { email: emailAddr } : {}, { onProgress: out });
|
|
2545
2592
|
const r = await requestSmsCode(store, method, codeOpts);
|
|
2546
2593
|
store.codePending = true;
|
|
2547
2594
|
saveStore(store, sessFile);
|
|
@@ -2567,7 +2614,7 @@ async function main() {
|
|
|
2567
2614
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2568
2615
|
out('verifying code for +' + ph + '...');
|
|
2569
2616
|
try {
|
|
2570
|
-
const r = await verifyCode(store, code, registrationPrompts());
|
|
2617
|
+
const r = await verifyCode(store, code, Object.assign(registrationPrompts(), { onProgress: out }));
|
|
2571
2618
|
if (r && (r.status === 'ok' || r.status === 'sent' || r.status === 'verified')) {
|
|
2572
2619
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2573
2620
|
const finalStore = r.store || store;
|
package/lib/AndroidApk.js
CHANGED
|
@@ -289,21 +289,31 @@ function _isDensitySplit(fileName) {
|
|
|
289
289
|
function extractMaterial(base, splits) {
|
|
290
290
|
const entries = readZipDirectory(base);
|
|
291
291
|
|
|
292
|
+
// Which about_logo.png, when there is more than one.
|
|
293
|
+
//
|
|
294
|
+
// A released bundle ships the drawable in every density split — seven of them
|
|
295
|
+
// — and its bytes are the PBKDF2 password, so picking a different density
|
|
296
|
+
// yields a different key and a token the server will not accept. Searching
|
|
297
|
+
// split by split makes the answer depend on the order the splits happen to be
|
|
298
|
+
// passed in, which for a shell glob is alphabetical and for Cobalt is whatever
|
|
299
|
+
// its download map iterated: same APK, different token.
|
|
300
|
+
//
|
|
301
|
+
// So the path decides, not the split. ABOUT_LOGO_PATHS is ordered — hdpi
|
|
302
|
+
// first, the xxhdpi fallback last — and every candidate is searched for the
|
|
303
|
+
// first path before any is searched for the second. The base APK keeps
|
|
304
|
+
// priority within each path, since a release that still carries the drawable
|
|
305
|
+
// itself is not a bundle at all.
|
|
292
306
|
let aboutLogo = null;
|
|
307
|
+
let aboutLogoFrom = null;
|
|
293
308
|
for (const p of ABOUT_LOGO_PATHS) {
|
|
294
309
|
aboutLogo = readZipEntry(base, entries, p);
|
|
295
|
-
if (aboutLogo) break;
|
|
296
|
-
}
|
|
297
|
-
if (!aboutLogo) {
|
|
310
|
+
if (aboutLogo) { aboutLogoFrom = p; break; }
|
|
298
311
|
for (const split of (splits || [])) {
|
|
299
312
|
if (!_isDensitySplit(split.name)) continue;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
aboutLogo = readZipEntry(split.data, sEntries, p);
|
|
303
|
-
if (aboutLogo) break;
|
|
304
|
-
}
|
|
305
|
-
if (aboutLogo) break;
|
|
313
|
+
aboutLogo = readZipEntry(split.data, readZipDirectory(split.data), p);
|
|
314
|
+
if (aboutLogo) { aboutLogoFrom = split.name + ':' + p; break; }
|
|
306
315
|
}
|
|
316
|
+
if (aboutLogo) break;
|
|
307
317
|
}
|
|
308
318
|
if (!aboutLogo) {
|
|
309
319
|
throw new Error('about_logo.png is in neither the base APK nor any density split — ' +
|
|
@@ -333,6 +343,9 @@ function extractMaterial(base, splits) {
|
|
|
333
343
|
secretKey: deriveSecretKey(packageName, aboutLogo),
|
|
334
344
|
classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
|
|
335
345
|
certificates,
|
|
346
|
+
// Which drawable the key came from — worth showing, since a bundle carries
|
|
347
|
+
// one per density and only one of them makes the token the server expects.
|
|
348
|
+
aboutLogoFrom,
|
|
336
349
|
// The version this APK is. Announced instead of the live one, because the
|
|
337
350
|
// token is signed over this build's classes.dex.
|
|
338
351
|
apkVersion: manifest.versionName,
|
|
@@ -366,6 +379,40 @@ function computeToken(material, national) {
|
|
|
366
379
|
return encodeURIComponent(mac.digest().toString('base64'));
|
|
367
380
|
}
|
|
368
381
|
|
|
382
|
+
// Who signed the APK, so it can be told apart from a repack.
|
|
383
|
+
//
|
|
384
|
+
// The token is an HMAC over the signing certificates, so it is only the token
|
|
385
|
+
// the server expects when those certificates are WhatsApp's own. An APK a mirror
|
|
386
|
+
// re-signed carries the mirror's certificate instead and produces a token that
|
|
387
|
+
// is perfectly well-formed and belongs to nobody — which comes back as
|
|
388
|
+
// bad_token, indistinguishable from having no material at all. Reading the
|
|
389
|
+
// subject out is what turns that into something visible before it is sent.
|
|
390
|
+
//
|
|
391
|
+
// Returns null on a runtime without X509Certificate (Node < 15.6) rather than
|
|
392
|
+
// making the extraction depend on it.
|
|
393
|
+
function describeCertificate(der) {
|
|
394
|
+
if (typeof crypto.X509Certificate !== 'function') return null;
|
|
395
|
+
try {
|
|
396
|
+
const cert = new crypto.X509Certificate(der);
|
|
397
|
+
return {
|
|
398
|
+
subject: cert.subject.replace(/\n/g, ', '),
|
|
399
|
+
issuer: cert.issuer.replace(/\n/g, ', '),
|
|
400
|
+
fingerprint256: cert.fingerprint256,
|
|
401
|
+
validFrom: cert.validFrom,
|
|
402
|
+
validTo: cert.validTo
|
|
403
|
+
};
|
|
404
|
+
} catch (_) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// WhatsApp's own signing certificate names the company in its subject. A repack
|
|
410
|
+
// cannot keep that — re-signing replaces the certificate outright — so a subject
|
|
411
|
+
// without it is the one thing worth saying out loud.
|
|
412
|
+
function looksLikeWhatsAppCertificate(description) {
|
|
413
|
+
return !!(description && /WhatsApp/i.test(description.subject));
|
|
414
|
+
}
|
|
415
|
+
|
|
369
416
|
// ─── Storage ──────────────────────────────────────────────────────────────────
|
|
370
417
|
//
|
|
371
418
|
// Only the derived pieces are kept, the way Cobalt caches them — the APK itself
|
|
@@ -399,6 +446,8 @@ function materialFromJson(json) {
|
|
|
399
446
|
module.exports = {
|
|
400
447
|
extractMaterial,
|
|
401
448
|
parseManifest,
|
|
449
|
+
describeCertificate,
|
|
450
|
+
looksLikeWhatsAppCertificate,
|
|
402
451
|
deriveSecretKey,
|
|
403
452
|
computeToken,
|
|
404
453
|
materialToJson,
|
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
|
@@ -137,7 +137,7 @@ function socksProxyUrl() {
|
|
|
137
137
|
return process.env.TOR_PROXY || process.env.SOCKS_PROXY || '';
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
140
|
+
async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
|
|
141
141
|
const proxy = parseSocksProxy(proxyUrl);
|
|
142
142
|
const dHost = 'v.whatsapp.net';
|
|
143
143
|
const dPort = 443;
|
|
@@ -164,7 +164,7 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader) {
|
|
|
164
164
|
tlsSocket.once('error', rej);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
const headers = registrationHeaders(getDeviceConfig(), waVersion);
|
|
167
|
+
const headers = registrationHeaders(device || getDeviceConfig(), waVersion);
|
|
168
168
|
const reqLines = [
|
|
169
169
|
`POST /v2${path} HTTP/1.1`,
|
|
170
170
|
`Host: ${dHost}`,
|
|
@@ -575,6 +575,62 @@ function tryLoadAndroidMaterial() {
|
|
|
575
575
|
try { return loadAndroidMaterial(); } catch (_) { return null; }
|
|
576
576
|
}
|
|
577
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, device) {
|
|
594
|
+
opts = opts || {};
|
|
595
|
+
device = 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
|
+
|
|
578
634
|
function loadAndroidMaterial() {
|
|
579
635
|
if (_androidMaterial) return _androidMaterial;
|
|
580
636
|
const file = androidMaterialPath();
|
|
@@ -582,18 +638,46 @@ function loadAndroidMaterial() {
|
|
|
582
638
|
try {
|
|
583
639
|
raw = fs.readFileSync(file, 'utf8');
|
|
584
640
|
} catch (_) {
|
|
585
|
-
|
|
586
|
-
'and there is none at ' + file + '.
|
|
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' +
|
|
587
645
|
' wa apk-material <base.apk> [split.apk ...]\n' +
|
|
588
|
-
'
|
|
589
|
-
|
|
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;
|
|
590
651
|
}
|
|
591
|
-
|
|
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;
|
|
592
676
|
return _androidMaterial;
|
|
593
677
|
}
|
|
594
678
|
|
|
595
|
-
function computeToken(waVersion, national) {
|
|
596
|
-
|
|
679
|
+
function computeToken(waVersion, national, device) {
|
|
680
|
+
device = device || getDeviceConfig();
|
|
597
681
|
if (device.os === 'android') {
|
|
598
682
|
return AndroidApk.computeToken(loadAndroidMaterial(), national);
|
|
599
683
|
}
|
|
@@ -733,8 +817,8 @@ function toBase64Url(buf) {
|
|
|
733
817
|
async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
734
818
|
const { cc, national } = parsePhone(store.phoneNumber);
|
|
735
819
|
const meta = getCountryMeta(cc);
|
|
736
|
-
const token = useToken ? computeToken(waVersion, national) : null;
|
|
737
820
|
const device = store.device || getDeviceConfig();
|
|
821
|
+
const token = useToken ? computeToken(waVersion, national, device) : null;
|
|
738
822
|
// Android formats fdid lowercase, iOS uppercase (matches the native clients).
|
|
739
823
|
const fdid = device.os === 'android'
|
|
740
824
|
? store.fdid.toLowerCase()
|
|
@@ -790,14 +874,14 @@ function encryptPayload(plaintext) {
|
|
|
790
874
|
|
|
791
875
|
// ---------- HTTP ----------
|
|
792
876
|
|
|
793
|
-
function httpPost(path, body, waVersion, authHeader) {
|
|
877
|
+
function httpPost(path, body, waVersion, authHeader, device) {
|
|
794
878
|
const proxy = socksProxyUrl();
|
|
795
|
-
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader);
|
|
879
|
+
if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device);
|
|
796
880
|
|
|
797
881
|
return new Promise((resolve, reject) => {
|
|
798
882
|
const bodyBuf = Buffer.from(body, 'utf8');
|
|
799
883
|
const headers = Object.assign(
|
|
800
|
-
registrationHeaders(getDeviceConfig(), waVersion),
|
|
884
|
+
registrationHeaders(device || getDeviceConfig(), waVersion),
|
|
801
885
|
{ 'Content-Length': bodyBuf.length }
|
|
802
886
|
);
|
|
803
887
|
// Attestation cert-chain / App Attest header (Android cert chain, iOS
|
|
@@ -859,7 +943,7 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
|
|
|
859
943
|
|
|
860
944
|
_whaDbg('[DBG] REG → ' + path + ' (' + body.length + ' bytes' +
|
|
861
945
|
(bodyAtt.bodyAttestation ? ', attested' : '') + ')');
|
|
862
|
-
const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader);
|
|
946
|
+
const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device);
|
|
863
947
|
_whaDbg('[DBG] REG ← ' + path + ' ' +
|
|
864
948
|
(result && result.status ? result.status : JSON.stringify(result).slice(0, 120)) +
|
|
865
949
|
(result && result.reason ? ' reason=' + result.reason : ''));
|
|
@@ -1109,7 +1193,7 @@ async function handle2FA(store, waVersion, opts) {
|
|
|
1109
1193
|
// ---------- Public API ----------
|
|
1110
1194
|
|
|
1111
1195
|
async function checkIfRegistered(store) {
|
|
1112
|
-
const waVersion = await fetchWaVersion(getDeviceConfig());
|
|
1196
|
+
const waVersion = await fetchWaVersion(store.device || getDeviceConfig());
|
|
1113
1197
|
return sendRequest('/exist', store, waVersion, false, null);
|
|
1114
1198
|
}
|
|
1115
1199
|
|
|
@@ -1247,7 +1331,14 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1247
1331
|
async function requestSmsCode(store, method, opts) {
|
|
1248
1332
|
method = method || 'sms';
|
|
1249
1333
|
opts = opts || {};
|
|
1250
|
-
|
|
1334
|
+
// Before the version is read, since on Android the version comes out of the
|
|
1335
|
+
// APK this fetches.
|
|
1336
|
+
// What a session registered as is settled when it is created, so it is the
|
|
1337
|
+
// store that decides — not whatever WA_OS the shell happens to carry now. A
|
|
1338
|
+
// confirmation run from a shell without the variables would otherwise sign an
|
|
1339
|
+
// Android registration with an iOS token.
|
|
1340
|
+
const _device = store.device || getDeviceConfig();
|
|
1341
|
+
await ensureAndroidMaterial(opts, _device);
|
|
1251
1342
|
const waVersion = await fetchWaVersion(_device);
|
|
1252
1343
|
store.version = waVersion;
|
|
1253
1344
|
store.device = _device;
|
|
@@ -1366,7 +1457,14 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1366
1457
|
|
|
1367
1458
|
async function verifyCode(store, code, opts) {
|
|
1368
1459
|
opts = opts || {};
|
|
1369
|
-
|
|
1460
|
+
// Normally already there from the code request, but a confirmation can be run
|
|
1461
|
+
// from a fresh shell that never made one.
|
|
1462
|
+
// What a session registered as is settled when it is created, so it is the
|
|
1463
|
+
// store that decides — not whatever WA_OS the shell happens to carry now. A
|
|
1464
|
+
// confirmation run from a shell without the variables would otherwise sign an
|
|
1465
|
+
// Android registration with an iOS token.
|
|
1466
|
+
const _device = store.device || getDeviceConfig();
|
|
1467
|
+
await ensureAndroidMaterial(opts, _device);
|
|
1370
1468
|
const waVersion = await fetchWaVersion(_device);
|
|
1371
1469
|
store.version = waVersion;
|
|
1372
1470
|
store.device = _device;
|
|
@@ -1461,7 +1559,7 @@ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyC
|
|
|
1461
1559
|
module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
|
|
1462
1560
|
|
|
1463
1561
|
// Token computation, exposed for tests. Not part of the public API.
|
|
1464
|
-
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders };
|
|
1562
|
+
module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial };
|
|
1465
1563
|
|
|
1466
1564
|
// Challenge / two-factor internals, exposed for tests. Not part of the public API.
|
|
1467
1565
|
module.exports._verify = {
|
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.12.
|
|
4
|
-
"description": "WhatsApp library for interaction with WhatsApp Mobile API
|
|
3
|
+
"version": "5.12.14",
|
|
4
|
+
"description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
|
|
5
5
|
"author": "Kunboruto20",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|