whalibmob 5.12.10 → 5.12.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -2
- package/README.md +156 -0
- package/cli.js +107 -7
- package/lib/AndroidApk.js +462 -0
- package/lib/PlayStore.js +381 -0
- package/lib/Registration.js +162 -8
- package/lib/constants.js +6 -3
- package/lib/playstore-device.js +45 -0
- package/package.json +2 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The Android registration token.
|
|
4
|
+
//
|
|
5
|
+
// iOS derives its token from a static string and the client version, and that
|
|
6
|
+
// is the algorithm this library used for both platforms — with a different
|
|
7
|
+
// constant substituted for Android. There is no such constant: the Android
|
|
8
|
+
// client signs the token with material taken out of its own APK, and no value
|
|
9
|
+
// put in WA_STATIC_TOKEN can stand in for it. Sending the iOS-shaped token as
|
|
10
|
+
// Android is answered with {"reason":"bad_token"}.
|
|
11
|
+
//
|
|
12
|
+
// What the native client actually computes, and what this file reproduces from
|
|
13
|
+
// Cobalt's WhatsAppAndroidClientInfo:
|
|
14
|
+
//
|
|
15
|
+
// key = PBKDF2-HMAC-SHA1(password = packageName || about_logo.png,
|
|
16
|
+
// salt = ANDROID_SALT, iterations = 128, dkLen = 64)
|
|
17
|
+
// mac = HMAC-SHA1(key) over each APK signing certificate in order,
|
|
18
|
+
// then MD5(classes.dex), then the national number as ASCII
|
|
19
|
+
// token = urlencode(base64(mac))
|
|
20
|
+
//
|
|
21
|
+
// The three pieces of material live in the APK, so they cannot be derived —
|
|
22
|
+
// they have to be read out of one. extractMaterial does that; the CLI's
|
|
23
|
+
// apk-material command drives it and writes the result where computeToken
|
|
24
|
+
// looks for it.
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
const zlib = require('zlib');
|
|
28
|
+
|
|
29
|
+
// Reverse engineered from the Android binary and identical for the consumer and
|
|
30
|
+
// business builds. Cobalt notes it has been stable across many releases.
|
|
31
|
+
const ANDROID_SALT = Buffer.from(
|
|
32
|
+
'PkTwKSZqUfAUyR0rPQ8hYJ0wNsQQ3dW1+3SCnyTXIfEAxxS75FwkDf47wNv/c8pP' +
|
|
33
|
+
'3p0GXKR6OOQmhyERwx74fw1RYSU10I4r1gyBVDbRJ40pidjM41G1I1oN', 'base64');
|
|
34
|
+
|
|
35
|
+
// Where about_logo.png has shipped across releases. The base APK is searched in
|
|
36
|
+
// this order first; App Bundle releases moved density-qualified drawables into
|
|
37
|
+
// per-density splits, which is why the splits are searched after it.
|
|
38
|
+
const ABOUT_LOGO_PATHS = [
|
|
39
|
+
'res/drawable-hdpi/about_logo.png',
|
|
40
|
+
'res/drawable-hdpi-v4/about_logo.png',
|
|
41
|
+
'res/drawable-xxhdpi-v4/about_logo.png'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const PERSONAL_PACKAGE = 'com.whatsapp';
|
|
45
|
+
const BUSINESS_PACKAGE = 'com.whatsapp.w4b';
|
|
46
|
+
|
|
47
|
+
const PBKDF2_ITERATIONS = 128;
|
|
48
|
+
const PBKDF2_KEY_SIZE = 64;
|
|
49
|
+
|
|
50
|
+
// ─── ZIP ──────────────────────────────────────────────────────────────────────
|
|
51
|
+
//
|
|
52
|
+
// An APK is a ZIP. Only two things are needed from it — a named entry's bytes
|
|
53
|
+
// and the list of entry names — so this reads the central directory and inflates
|
|
54
|
+
// on demand rather than pulling in an archive library.
|
|
55
|
+
|
|
56
|
+
function _findEndOfCentralDirectory(buf) {
|
|
57
|
+
// The record is 22 bytes plus a comment of up to 64 KiB, so it sits within the
|
|
58
|
+
// last 64 KiB + 22. Scanning backwards finds the real one before any earlier
|
|
59
|
+
// byte sequence that happens to look like the signature.
|
|
60
|
+
const min = Math.max(0, buf.length - (0xffff + 22));
|
|
61
|
+
for (let i = buf.length - 22; i >= min; i--) {
|
|
62
|
+
if (buf.readUInt32LE(i) === 0x06054b50) return i;
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// name → { method, compressedSize, size, localHeaderOffset }, in central
|
|
68
|
+
// directory order.
|
|
69
|
+
function readZipDirectory(buf) {
|
|
70
|
+
const eocd = _findEndOfCentralDirectory(buf);
|
|
71
|
+
if (eocd < 0) throw new Error('not a zip archive: no end-of-central-directory record');
|
|
72
|
+
|
|
73
|
+
let count = buf.readUInt16LE(eocd + 10);
|
|
74
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
75
|
+
|
|
76
|
+
// ZIP64: the 32-bit fields are saturated and the real ones live in the ZIP64
|
|
77
|
+
// record the locator points at. A WhatsApp APK is far from needing it, but an
|
|
78
|
+
// archive that does would otherwise be read as garbage rather than refused.
|
|
79
|
+
if (count === 0xffff || offset === 0xffffffff) {
|
|
80
|
+
const loc = eocd - 20;
|
|
81
|
+
if (loc < 0 || buf.readUInt32LE(loc) !== 0x07064b50) {
|
|
82
|
+
throw new Error('zip needs ZIP64 but carries no ZIP64 locator');
|
|
83
|
+
}
|
|
84
|
+
const z64 = Number(buf.readBigUInt64LE(loc + 8));
|
|
85
|
+
if (buf.readUInt32LE(z64) !== 0x06064b50) throw new Error('bad ZIP64 end-of-central-directory');
|
|
86
|
+
count = Number(buf.readBigUInt64LE(z64 + 32));
|
|
87
|
+
offset = Number(buf.readBigUInt64LE(z64 + 48));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entries = new Map();
|
|
91
|
+
let p = offset;
|
|
92
|
+
for (let i = 0; i < count; i++) {
|
|
93
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) break;
|
|
94
|
+
const method = buf.readUInt16LE(p + 10);
|
|
95
|
+
const compressedSize = buf.readUInt32LE(p + 20);
|
|
96
|
+
const size = buf.readUInt32LE(p + 24);
|
|
97
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
98
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
99
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
100
|
+
const localHeaderOffset = buf.readUInt32LE(p + 42);
|
|
101
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
102
|
+
entries.set(name, { method, compressedSize, size, localHeaderOffset });
|
|
103
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readZipEntry(buf, entries, name) {
|
|
109
|
+
const e = entries.get(name);
|
|
110
|
+
if (!e) return null;
|
|
111
|
+
const lh = e.localHeaderOffset;
|
|
112
|
+
if (buf.readUInt32LE(lh) !== 0x04034b50) throw new Error('bad local header for ' + name);
|
|
113
|
+
// The local header's extra field is allowed to differ in length from the
|
|
114
|
+
// central directory's, so the data offset is computed from the local one.
|
|
115
|
+
const nameLen = buf.readUInt16LE(lh + 26);
|
|
116
|
+
const extraLen = buf.readUInt16LE(lh + 28);
|
|
117
|
+
const start = lh + 30 + nameLen + extraLen;
|
|
118
|
+
const raw = buf.slice(start, start + e.compressedSize);
|
|
119
|
+
if (e.method === 0) return raw;
|
|
120
|
+
if (e.method === 8) return zlib.inflateRawSync(raw);
|
|
121
|
+
throw new Error('unsupported zip compression method ' + e.method + ' for ' + name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── DER / PKCS#7 ─────────────────────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// The signing certificates come out of the v1 (JAR) signature block, which is
|
|
127
|
+
// what Cobalt reads — apk-parser's getApkSingers() is the v1 signer list. Only
|
|
128
|
+
// enough DER is parsed to walk into SignedData and lift each certificate out
|
|
129
|
+
// whole; nothing here validates a signature.
|
|
130
|
+
|
|
131
|
+
function _readTlv(buf, pos) {
|
|
132
|
+
const tag = buf[pos];
|
|
133
|
+
let p = pos + 1;
|
|
134
|
+
let len = buf[p++];
|
|
135
|
+
if (len & 0x80) {
|
|
136
|
+
const n = len & 0x7f;
|
|
137
|
+
if (n === 0) throw new Error('indefinite DER length');
|
|
138
|
+
len = 0;
|
|
139
|
+
for (let i = 0; i < n; i++) len = (len << 8) | buf[p++];
|
|
140
|
+
}
|
|
141
|
+
return { tag, headerLength: p - pos, length: len, contentStart: p, end: p + len };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Every certificate in a PKCS#7 SignedData, each as its own DER blob, in the
|
|
145
|
+
// order the structure carries them.
|
|
146
|
+
function certificatesFromPkcs7(der) {
|
|
147
|
+
const contentInfo = _readTlv(der, 0); // SEQUENCE
|
|
148
|
+
let p = contentInfo.contentStart;
|
|
149
|
+
const oid = _readTlv(der, p); // contentType OID
|
|
150
|
+
p = oid.end;
|
|
151
|
+
const explicit = _readTlv(der, p); // [0] EXPLICIT
|
|
152
|
+
const signedData = _readTlv(der, explicit.contentStart);
|
|
153
|
+
|
|
154
|
+
// SignedData ::= version, digestAlgorithms, contentInfo, [0] certificates …
|
|
155
|
+
let q = signedData.contentStart;
|
|
156
|
+
while (q < signedData.end) {
|
|
157
|
+
const field = _readTlv(der, q);
|
|
158
|
+
if (field.tag === 0xa0) { // [0] IMPLICIT certificates
|
|
159
|
+
const out = [];
|
|
160
|
+
let c = field.contentStart;
|
|
161
|
+
while (c < field.end) {
|
|
162
|
+
const cert = _readTlv(der, c);
|
|
163
|
+
if (cert.tag === 0x30) out.push(der.slice(c, cert.end));
|
|
164
|
+
c = cert.end;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
q = field.end;
|
|
169
|
+
}
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Binary AndroidManifest.xml ───────────────────────────────────────────────
|
|
174
|
+
//
|
|
175
|
+
// The manifest inside an APK is Android's binary XML, not text. Three things are
|
|
176
|
+
// read out of it — the package name, the version name and the version code —
|
|
177
|
+
// the same three Cobalt takes from its parsed manifest. The version name is the
|
|
178
|
+
// one that matters most: the token is signed over this APK's classes.dex, so the
|
|
179
|
+
// version announced to the server has to be this APK's, not whatever the Play
|
|
180
|
+
// Store currently lists.
|
|
181
|
+
//
|
|
182
|
+
// Chunk layout is AOSP's ResourceTypes.h. Only what is needed to reach the
|
|
183
|
+
// attributes of the first element is decoded.
|
|
184
|
+
|
|
185
|
+
const _RES_STRING_POOL_TYPE = 0x0001;
|
|
186
|
+
const _RES_XML_START_ELEMENT = 0x0102;
|
|
187
|
+
const _UTF8_FLAG = 0x0100;
|
|
188
|
+
const _TYPE_STRING = 0x03;
|
|
189
|
+
|
|
190
|
+
function _readStringPool(buf, pos) {
|
|
191
|
+
const size = buf.readUInt32LE(pos + 4);
|
|
192
|
+
const stringCount = buf.readUInt32LE(pos + 8);
|
|
193
|
+
const flags = buf.readUInt32LE(pos + 16);
|
|
194
|
+
const stringsStart = buf.readUInt32LE(pos + 20);
|
|
195
|
+
const utf8 = (flags & _UTF8_FLAG) !== 0;
|
|
196
|
+
|
|
197
|
+
const strings = [];
|
|
198
|
+
for (let i = 0; i < stringCount; i++) {
|
|
199
|
+
const offset = buf.readUInt32LE(pos + 28 + i * 4);
|
|
200
|
+
let p = pos + stringsStart + offset;
|
|
201
|
+
if (p >= pos + size) { strings.push(''); continue; }
|
|
202
|
+
if (utf8) {
|
|
203
|
+
// Two lengths, characters then bytes, each one or two bytes wide.
|
|
204
|
+
let charLen = buf[p++];
|
|
205
|
+
if (charLen & 0x80) charLen = ((charLen & 0x7f) << 8) | buf[p++];
|
|
206
|
+
let byteLen = buf[p++];
|
|
207
|
+
if (byteLen & 0x80) byteLen = ((byteLen & 0x7f) << 8) | buf[p++];
|
|
208
|
+
strings.push(buf.toString('utf8', p, p + byteLen));
|
|
209
|
+
} else {
|
|
210
|
+
let len = buf.readUInt16LE(p); p += 2;
|
|
211
|
+
if (len & 0x8000) { len = ((len & 0x7fff) << 16) | buf.readUInt16LE(p); p += 2; }
|
|
212
|
+
strings.push(buf.toString('utf16le', p, p + len * 2));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return strings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* { packageName, versionName, versionCode } out of a binary AndroidManifest.xml.
|
|
220
|
+
* Any field the manifest does not carry comes back null.
|
|
221
|
+
*/
|
|
222
|
+
function parseManifest(buf) {
|
|
223
|
+
const empty = { packageName: null, versionName: null, versionCode: null };
|
|
224
|
+
if (!buf || buf.length < 8) return empty;
|
|
225
|
+
|
|
226
|
+
let strings = null;
|
|
227
|
+
let pos = 8; // past the file's own chunk header
|
|
228
|
+
while (pos + 8 <= buf.length) {
|
|
229
|
+
const type = buf.readUInt16LE(pos);
|
|
230
|
+
const chunkSize = buf.readUInt32LE(pos + 4);
|
|
231
|
+
if (chunkSize <= 0 || pos + chunkSize > buf.length) break;
|
|
232
|
+
|
|
233
|
+
if (type === _RES_STRING_POOL_TYPE) {
|
|
234
|
+
strings = _readStringPool(buf, pos);
|
|
235
|
+
} else if (type === _RES_XML_START_ELEMENT && strings) {
|
|
236
|
+
// ResXMLTree_node (8 bytes) then ResXMLTree_attrExt.
|
|
237
|
+
const ext = pos + 16;
|
|
238
|
+
const nameIndex = buf.readUInt32LE(ext + 4);
|
|
239
|
+
const attributeStart = buf.readUInt16LE(ext + 8);
|
|
240
|
+
const attributeSize = buf.readUInt16LE(ext + 10);
|
|
241
|
+
const attributeCount = buf.readUInt16LE(ext + 12);
|
|
242
|
+
if (strings[nameIndex] !== 'manifest') { pos += chunkSize; continue; }
|
|
243
|
+
|
|
244
|
+
const out = Object.assign({}, empty);
|
|
245
|
+
for (let i = 0; i < attributeCount; i++) {
|
|
246
|
+
const a = ext + attributeStart + i * attributeSize;
|
|
247
|
+
const attrName = strings[buf.readUInt32LE(a + 4)];
|
|
248
|
+
const rawValue = buf.readUInt32LE(a + 8);
|
|
249
|
+
const dataType = buf[a + 15];
|
|
250
|
+
const data = buf.readUInt32LE(a + 16);
|
|
251
|
+
// A string attribute names its value in the pool twice — the raw source
|
|
252
|
+
// text and the typed value. Either will do; the raw one is absent when
|
|
253
|
+
// the manifest was written without it.
|
|
254
|
+
const asString = () =>
|
|
255
|
+
(rawValue !== 0xffffffff && strings[rawValue] !== undefined) ? strings[rawValue]
|
|
256
|
+
: (dataType === _TYPE_STRING ? strings[data] : String(data));
|
|
257
|
+
|
|
258
|
+
if (attrName === 'package') out.packageName = asString();
|
|
259
|
+
else if (attrName === 'versionName') out.versionName = asString();
|
|
260
|
+
else if (attrName === 'versionCode') out.versionCode = dataType === _TYPE_STRING
|
|
261
|
+
? parseInt(strings[data], 10) : data;
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
pos += chunkSize;
|
|
266
|
+
}
|
|
267
|
+
return empty;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A split's own name, as Android files it: "config.xxhdpi" out of
|
|
271
|
+
// "base-config.xxhdpi.apk" or "split_config.xxhdpi.apk". Cobalt only searches
|
|
272
|
+
// splits whose name ends in "dpi", which is the density-qualified set.
|
|
273
|
+
function _isDensitySplit(fileName) {
|
|
274
|
+
const base = String(fileName).replace(/\.apk$/i, '');
|
|
275
|
+
return /dpi$/i.test(base);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read the token material out of a WhatsApp APK.
|
|
280
|
+
*
|
|
281
|
+
* base: Buffer with the base APK.
|
|
282
|
+
* splits: optional [{ name, data }] for the density splits, searched only when
|
|
283
|
+
* the base APK does not carry about_logo.png — which is the case for
|
|
284
|
+
* every App Bundle release.
|
|
285
|
+
*
|
|
286
|
+
* Returns { packageName, secretKey, classesDexMd5, certificates } with the
|
|
287
|
+
* three derived pieces as Buffers, ready for computeToken.
|
|
288
|
+
*/
|
|
289
|
+
function extractMaterial(base, splits) {
|
|
290
|
+
const entries = readZipDirectory(base);
|
|
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.
|
|
306
|
+
let aboutLogo = null;
|
|
307
|
+
let aboutLogoFrom = null;
|
|
308
|
+
for (const p of ABOUT_LOGO_PATHS) {
|
|
309
|
+
aboutLogo = readZipEntry(base, entries, p);
|
|
310
|
+
if (aboutLogo) { aboutLogoFrom = p; break; }
|
|
311
|
+
for (const split of (splits || [])) {
|
|
312
|
+
if (!_isDensitySplit(split.name)) continue;
|
|
313
|
+
aboutLogo = readZipEntry(split.data, readZipDirectory(split.data), p);
|
|
314
|
+
if (aboutLogo) { aboutLogoFrom = split.name + ':' + p; break; }
|
|
315
|
+
}
|
|
316
|
+
if (aboutLogo) break;
|
|
317
|
+
}
|
|
318
|
+
if (!aboutLogo) {
|
|
319
|
+
throw new Error('about_logo.png is in neither the base APK nor any density split — ' +
|
|
320
|
+
'pass the split APKs alongside the base one (' + ABOUT_LOGO_PATHS.join(', ') + ')');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const classesDex = readZipEntry(base, entries, 'classes.dex');
|
|
324
|
+
if (!classesDex) throw new Error('classes.dex missing from the base APK');
|
|
325
|
+
|
|
326
|
+
const certificates = [];
|
|
327
|
+
for (const name of entries.keys()) {
|
|
328
|
+
if (!/^META-INF\/[^/]+\.(RSA|DSA|EC)$/i.test(name)) continue;
|
|
329
|
+
for (const cert of certificatesFromPkcs7(readZipEntry(base, entries, name))) {
|
|
330
|
+
certificates.push(cert);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!certificates.length) {
|
|
334
|
+
throw new Error('no v1 signing certificate in META-INF — this APK is not JAR-signed, ' +
|
|
335
|
+
'and the token is built from the v1 signature block');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const manifest = parseManifest(readZipEntry(base, entries, 'AndroidManifest.xml'));
|
|
339
|
+
const packageName = manifest.packageName || PERSONAL_PACKAGE;
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
packageName,
|
|
343
|
+
secretKey: deriveSecretKey(packageName, aboutLogo),
|
|
344
|
+
classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
|
|
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,
|
|
349
|
+
// The version this APK is. Announced instead of the live one, because the
|
|
350
|
+
// token is signed over this build's classes.dex.
|
|
351
|
+
apkVersion: manifest.versionName,
|
|
352
|
+
apkVersionCode: manifest.versionCode
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// PBKDF2-HMAC-SHA1 over the package name followed by the raw PNG bytes.
|
|
357
|
+
//
|
|
358
|
+
// Cobalt runs the derivation by hand because the JCA factory rejects a binary
|
|
359
|
+
// password; the loop it runs is standard PBKDF2, so this calls the platform's.
|
|
360
|
+
// The two are checked against each other in the token test.
|
|
361
|
+
function deriveSecretKey(packageName, aboutLogo) {
|
|
362
|
+
const password = Buffer.concat([Buffer.from(packageName, 'utf8'), aboutLogo]);
|
|
363
|
+
return crypto.pbkdf2Sync(password, ANDROID_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_SIZE, 'sha1');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The registration token for a national number, as the Android client signs it.
|
|
368
|
+
*
|
|
369
|
+
* material: as returned by extractMaterial (or loaded from its JSON form).
|
|
370
|
+
* national: the number without the country code, digits only.
|
|
371
|
+
*/
|
|
372
|
+
function computeToken(material, national) {
|
|
373
|
+
const mac = crypto.createHmac('sha1', material.secretKey);
|
|
374
|
+
for (const cert of material.certificates) mac.update(cert);
|
|
375
|
+
mac.update(material.classesDexMd5);
|
|
376
|
+
mac.update(Buffer.from(String(national), 'utf8'));
|
|
377
|
+
// Java's URLEncoder over base64: '+', '/' and '=' are the only characters it
|
|
378
|
+
// touches, and encodeURIComponent escapes those three identically.
|
|
379
|
+
return encodeURIComponent(mac.digest().toString('base64'));
|
|
380
|
+
}
|
|
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
|
+
|
|
416
|
+
// ─── Storage ──────────────────────────────────────────────────────────────────
|
|
417
|
+
//
|
|
418
|
+
// Only the derived pieces are kept, the way Cobalt caches them — the APK itself
|
|
419
|
+
// is never needed again.
|
|
420
|
+
|
|
421
|
+
function materialToJson(material) {
|
|
422
|
+
return {
|
|
423
|
+
packageName: material.packageName,
|
|
424
|
+
secretKey: material.secretKey.toString('base64'),
|
|
425
|
+
classesDexMd5: material.classesDexMd5.toString('base64'),
|
|
426
|
+
certificates: material.certificates.map(c => c.toString('base64')),
|
|
427
|
+
apkVersion: material.apkVersion || null,
|
|
428
|
+
apkVersionCode: material.apkVersionCode || null
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function materialFromJson(json) {
|
|
433
|
+
if (!json || !json.secretKey || !json.classesDexMd5 || !Array.isArray(json.certificates)) {
|
|
434
|
+
throw new Error('android APK material file is incomplete');
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
packageName: json.packageName || PERSONAL_PACKAGE,
|
|
438
|
+
secretKey: Buffer.from(json.secretKey, 'base64'),
|
|
439
|
+
classesDexMd5: Buffer.from(json.classesDexMd5, 'base64'),
|
|
440
|
+
certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
|
|
441
|
+
apkVersion: json.apkVersion || null,
|
|
442
|
+
apkVersionCode: json.apkVersionCode || null
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
module.exports = {
|
|
447
|
+
extractMaterial,
|
|
448
|
+
parseManifest,
|
|
449
|
+
describeCertificate,
|
|
450
|
+
looksLikeWhatsAppCertificate,
|
|
451
|
+
deriveSecretKey,
|
|
452
|
+
computeToken,
|
|
453
|
+
materialToJson,
|
|
454
|
+
materialFromJson,
|
|
455
|
+
certificatesFromPkcs7,
|
|
456
|
+
readZipDirectory,
|
|
457
|
+
readZipEntry,
|
|
458
|
+
ANDROID_SALT,
|
|
459
|
+
ABOUT_LOGO_PATHS,
|
|
460
|
+
PERSONAL_PACKAGE,
|
|
461
|
+
BUSINESS_PACKAGE
|
|
462
|
+
};
|