whalibmob 5.33.23 → 5.33.28

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/README.md CHANGED
@@ -45,7 +45,7 @@ code, and bring the account into being. Both transports, one API.
45
45
  > wa registration --request-code <number> --method wa_old
46
46
  > wa registration --register <number> --code <code>
47
47
  > ```
48
- >
48
+ >
49
49
  > **This is optional.** Most numbers register fine with `--method sms` or `--method voice` — try those first. Use the `wa_old` route **only if** you want a guaranteed, 100% success rate on numbers where SMS/voice are blocked.
50
50
 
51
51
  ##
@@ -6171,4 +6171,5 @@ The **Sponsor** button at the top of the repository leads back here.
6171
6171
 
6172
6172
  ## License
6173
6173
 
6174
- MIT
6174
+ MIT
6175
+ .
package/lib/AndroidApk.js CHANGED
@@ -215,12 +215,14 @@ function _readStringPool(buf, pos) {
215
215
  }
216
216
 
217
217
  /**
218
- * { packageName, versionName, versionCode } out of a binary AndroidManifest.xml.
219
- * Any field the manifest does not carry comes back null.
218
+ * { packageName, versionName, versionCode, permissions } out of a binary
219
+ * AndroidManifest.xml. Any field the manifest does not carry comes back null,
220
+ * and permissions is the list of every <uses-permission> /
221
+ * <uses-permission-sdk-23> name, in manifest order (the _gp hash sorts them).
220
222
  */
221
223
  function parseManifest(buf) {
222
- const empty = { packageName: null, versionName: null, versionCode: null };
223
- if (!buf || buf.length < 8) return empty;
224
+ const out = { packageName: null, versionName: null, versionCode: null, permissions: [] };
225
+ if (!buf || buf.length < 8) return out;
224
226
 
225
227
  let strings = null;
226
228
  let pos = 8; // past the file's own chunk header
@@ -238,32 +240,37 @@ function parseManifest(buf) {
238
240
  const attributeStart = buf.readUInt16LE(ext + 8);
239
241
  const attributeSize = buf.readUInt16LE(ext + 10);
240
242
  const attributeCount = buf.readUInt16LE(ext + 12);
241
- if (strings[nameIndex] !== 'manifest') { pos += chunkSize; continue; }
242
-
243
- const out = Object.assign({}, empty);
244
- for (let i = 0; i < attributeCount; i++) {
245
- const a = ext + attributeStart + i * attributeSize;
246
- const attrName = strings[buf.readUInt32LE(a + 4)];
247
- const rawValue = buf.readUInt32LE(a + 8);
248
- const dataType = buf[a + 15];
249
- const data = buf.readUInt32LE(a + 16);
250
- // A string attribute names its value in the pool twice — the raw source
251
- // text and the typed value. Either will do; the raw one is absent when
252
- // the manifest was written without it.
253
- const asString = () =>
254
- (rawValue !== 0xffffffff && strings[rawValue] !== undefined) ? strings[rawValue]
255
- : (dataType === _TYPE_STRING ? strings[data] : String(data));
256
-
257
- if (attrName === 'package') out.packageName = asString();
258
- else if (attrName === 'versionName') out.versionName = asString();
259
- else if (attrName === 'versionCode') out.versionCode = dataType === _TYPE_STRING
260
- ? parseInt(strings[data], 10) : data;
243
+ const elementName = strings[nameIndex];
244
+
245
+ const attr = (wantName) => {
246
+ for (let i = 0; i < attributeCount; i++) {
247
+ const a = ext + attributeStart + i * attributeSize;
248
+ if (strings[buf.readUInt32LE(a + 4)] !== wantName) continue;
249
+ const rawValue = buf.readUInt32LE(a + 8);
250
+ const dataType = buf[a + 15];
251
+ const data = buf.readUInt32LE(a + 16);
252
+ // A string attribute names its value in the pool twice — the raw
253
+ // source text and the typed value. Either will do; the raw one is
254
+ // absent when the manifest was written without it.
255
+ return (rawValue !== 0xffffffff && strings[rawValue] !== undefined) ? strings[rawValue]
256
+ : (dataType === _TYPE_STRING ? strings[data] : String(data));
257
+ }
258
+ return null;
259
+ };
260
+
261
+ if (elementName === 'manifest') {
262
+ out.packageName = attr('package');
263
+ out.versionName = attr('versionName');
264
+ const vc = attr('versionCode');
265
+ out.versionCode = vc == null ? null : (/^\d+$/.test(vc) ? parseInt(vc, 10) : vc);
266
+ } else if (elementName === 'uses-permission' || elementName === 'uses-permission-sdk-23') {
267
+ const name = attr('name');
268
+ if (name) out.permissions.push(name);
261
269
  }
262
- return out;
263
270
  }
264
271
  pos += chunkSize;
265
272
  }
266
- return empty;
273
+ return out;
267
274
  }
268
275
 
269
276
  // A split's own name, as Android files it: "config.xxhdpi" out of
@@ -339,8 +346,17 @@ function extractMaterial(base, splits) {
339
346
 
340
347
  return {
341
348
  packageName,
342
- secretKey: deriveSecretKey(packageName, aboutLogo),
343
- classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
349
+ secretKey: deriveSecretKey(packageName, aboutLogo),
350
+ classesDexMd5: crypto.createHash('md5').update(classesDex).digest(),
351
+ // What _gi reports about the installed base.apk: the dex, the whole file,
352
+ // its first 10 MiB and its size.
353
+ classesDexSha256: crypto.createHash('sha256').update(classesDex).digest(),
354
+ apkSize: base.length,
355
+ apkSha256Full: crypto.createHash('sha256').update(base).digest(),
356
+ apkSha256First10M: crypto.createHash('sha256').update(base.subarray(0, 0xA00000)).digest(),
357
+ // _gp: the permission-list hash, computed from this APK's own manifest so
358
+ // it tracks the version rather than a pinned constant.
359
+ gpHash: permissionsHash(manifest.permissions),
344
360
  certificates,
345
361
  // Which drawable the key came from — worth showing, since a bundle carries
346
362
  // one per density and only one of them makes the token the server expects.
@@ -352,6 +368,25 @@ function extractMaterial(base, splits) {
352
368
  };
353
369
  }
354
370
 
371
+ // The _gp value: base64 SHA-256 of the sorted, separator-less concatenation of
372
+ // the requested permission names, as PackageManager.getRequestedPermissions()
373
+ // hands them to the app. On Android 12+ the platform drops the legacy
374
+ // android.permission.BLUETOOTH when BLUETOOTH_CONNECT is present, so the hash
375
+ // matches the device only once that one name is removed. Returns null when the
376
+ // manifest carried no permissions (nothing to hash).
377
+ const _PERM_BLUETOOTH = 'android.permission.BLUETOOTH';
378
+ const _PERM_BLUETOOTH_CONNECT = 'android.permission.BLUETOOTH_CONNECT';
379
+
380
+ function permissionsHash(permissions) {
381
+ if (!Array.isArray(permissions) || !permissions.length) return null;
382
+ let names = permissions.slice();
383
+ if (names.includes(_PERM_BLUETOOTH_CONNECT)) {
384
+ names = names.filter(n => n !== _PERM_BLUETOOTH);
385
+ }
386
+ names.sort();
387
+ return crypto.createHash('sha256').update(Buffer.from(names.join(''), 'utf8')).digest('base64');
388
+ }
389
+
355
390
  // PBKDF2-HMAC-SHA1 over the package name followed by the raw PNG bytes.
356
391
  //
357
392
  // A JCA factory rejects a binary password, so a hand-rolled loop is the usual
@@ -417,12 +452,17 @@ function looksLikeWhatsAppCertificate(description) {
417
452
 
418
453
  function materialToJson(material) {
419
454
  return {
420
- packageName: material.packageName,
421
- secretKey: material.secretKey.toString('base64'),
422
- classesDexMd5: material.classesDexMd5.toString('base64'),
423
- certificates: material.certificates.map(c => c.toString('base64')),
424
- apkVersion: material.apkVersion || null,
425
- apkVersionCode: material.apkVersionCode || null
455
+ packageName: material.packageName,
456
+ secretKey: material.secretKey.toString('base64'),
457
+ classesDexMd5: material.classesDexMd5.toString('base64'),
458
+ classesDexSha256: material.classesDexSha256 ? material.classesDexSha256.toString('base64') : null,
459
+ apkSize: material.apkSize != null ? material.apkSize : null,
460
+ apkSha256Full: material.apkSha256Full ? material.apkSha256Full.toString('base64') : null,
461
+ apkSha256First10M: material.apkSha256First10M ? material.apkSha256First10M.toString('base64') : null,
462
+ gpHash: material.gpHash || null,
463
+ certificates: material.certificates.map(c => c.toString('base64')),
464
+ apkVersion: material.apkVersion || null,
465
+ apkVersionCode: material.apkVersionCode || null
426
466
  };
427
467
  }
428
468
 
@@ -431,12 +471,17 @@ function materialFromJson(json) {
431
471
  throw new Error('android APK material file is incomplete');
432
472
  }
433
473
  return {
434
- packageName: json.packageName || PERSONAL_PACKAGE,
435
- secretKey: Buffer.from(json.secretKey, 'base64'),
436
- classesDexMd5: Buffer.from(json.classesDexMd5, 'base64'),
437
- certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
438
- apkVersion: json.apkVersion || null,
439
- apkVersionCode: json.apkVersionCode || null
474
+ packageName: json.packageName || PERSONAL_PACKAGE,
475
+ secretKey: Buffer.from(json.secretKey, 'base64'),
476
+ classesDexMd5: Buffer.from(json.classesDexMd5, 'base64'),
477
+ classesDexSha256: json.classesDexSha256 ? Buffer.from(json.classesDexSha256, 'base64') : null,
478
+ apkSize: json.apkSize != null ? json.apkSize : null,
479
+ apkSha256Full: json.apkSha256Full ? Buffer.from(json.apkSha256Full, 'base64') : null,
480
+ apkSha256First10M: json.apkSha256First10M ? Buffer.from(json.apkSha256First10M, 'base64') : null,
481
+ gpHash: json.gpHash || null,
482
+ certificates: json.certificates.map(c => Buffer.from(c, 'base64')),
483
+ apkVersion: json.apkVersion || null,
484
+ apkVersionCode: json.apkVersionCode || null
440
485
  };
441
486
  }
442
487
 
@@ -446,6 +491,7 @@ module.exports = {
446
491
  describeCertificate,
447
492
  looksLikeWhatsAppCertificate,
448
493
  deriveSecretKey,
494
+ permissionsHash,
449
495
  computeToken,
450
496
  materialToJson,
451
497
  materialFromJson,
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
-
2
+ //
3
3
  // ─── Device attestation client ────────────────────────────────────────────────
4
4
  //
5
5
  // Talks to the on-device Frida attestation server shipped in ./frida (Android
@@ -64,15 +64,29 @@ function generateGeToken() {
64
64
  }
65
65
 
66
66
  // ─── _ga: app analytics envelope ─────────────────────────────────────────────
67
- // bi = SHA-512 of the session nonce (deterministic per session, unique per
68
- // device); ap/ai/ae are numeric constants observed across Android builds;
69
- // mp/mu are multi-profile/multi-user booleans (false on a single-user install).
70
- function generateGaToken(nonceB64) {
71
- const nonceBuf = Buffer.from(
72
- (nonceB64 || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64'
73
- );
74
- const bi = crypto.createHash('sha512').update(nonceBuf).digest('base64');
75
- const json = '{"bi":"' + bi + '","ap":247444,"ai":145847,"mp":false,"ae":128383,"mu":false}';
67
+ // bi AES-256-CBC (key = SHA256(standard base64 of the auth key), random IV
68
+ // prepended) over the base64 of 32 random bytes: 88 base64 characters
69
+ // ap age of base.apk in seconds
70
+ // ai age of the oldest file in the data directory
71
+ // ae age of the oldest file in the external files directory, a little
72
+ // younger than ai
73
+ // mp/mu monkey-test flags, false on a device a person is using
74
+ //
75
+ // The ages model a fresh install, drawn per request: fixed numbers would be
76
+ // the same on every registration this library makes, which no phone does.
77
+ function generateGaToken(authKeyRaw) {
78
+ const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
79
+ const plaintext = Buffer.from(crypto.randomBytes(32).toString('base64'), 'utf8');
80
+ const iv = crypto.randomBytes(16);
81
+ const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
82
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
83
+ const bi = Buffer.concat([iv, ct]).toString('base64').replace(/\//g, '\\/');
84
+
85
+ const ai = 120 + crypto.randomInt(7081); // 2 min .. 2 h
86
+ const ap = Math.max(1, ai + crypto.randomInt(601) - 300); // ai ± 5 min
87
+ const ae = ai - (30 + crypto.randomInt(91)); // ai − 30..120 s
88
+
89
+ const json = '{"bi":"' + bi + '","ap":' + ap + ',"ai":' + ai + ',"mp":false,"ae":' + ae + ',"mu":false}';
76
90
  return encodeURIComponent(json);
77
91
  }
78
92
 
@@ -120,6 +134,92 @@ function generateGsToken() {
120
134
  return encodeURIComponent('{"em":"' + em + '"}');
121
135
  }
122
136
 
137
+ // ─── Integrity envelope shared by _gg and _gi ────────────────────────────────
138
+ // AES-256-CBC, key = SHA256(standard base64 of the 32-byte auth key), a random
139
+ // 16-byte IV prepended to the ciphertext, then standard base64.
140
+ function integrityEnvelope(authKeyRaw, json) {
141
+ const aesKey = sha256(Buffer.from(authKeyRaw.toString('base64'), 'utf8'));
142
+ const iv = crypto.randomBytes(16);
143
+ const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
144
+ const ct = Buffer.concat([cipher.update(Buffer.from(json, 'utf8')), cipher.final()]);
145
+ return encodeURIComponent(Buffer.concat([iv, ct]).toString('base64'));
146
+ }
147
+
148
+ // ─── _gg: Play Integrity status ──────────────────────────────────────────────
149
+ // {"_ic": N, "_it": "<token>"}
150
+ // _ic — 0 on success, 1003 when the provider is not prepared (no GMS)
151
+ // _it — the Play Integrity token, or the literal "undefined" on error
152
+ function generateGgToken(authKeyRaw, icCode, itToken) {
153
+ const json = '{"_ic":' + icCode + ',"_it":"' + String(itToken).replace(/\//g, '\\/') + '"}';
154
+ return integrityEnvelope(authKeyRaw, json);
155
+ }
156
+
157
+ // ─── _gp: SHA-256 of the manifest permission list ────────────────────────────
158
+ // Base64 SHA-256 of the sorted, separator-less permission names, percent-encoded
159
+ // on the wire. Computed from the installed APK's own manifest (material.gpHash)
160
+ // so it tracks the version. The constant is the known value for 2.26.35.75+ on
161
+ // Android 12+ (85 permissions) and is used when no material hash is available —
162
+ // an older material file, or a build read without the manifest permissions.
163
+ const GP_ANDROID12 = 'XpccAx8EKQswlTL2MtpCnGIMXTCaUbAf95ElLECc6jk=';
164
+
165
+ function generateGpToken(material) {
166
+ const hash = (material && material.gpHash) || GP_ANDROID12;
167
+ return encodeURIComponent(hash);
168
+ }
169
+
170
+ // ─── _gi: installation integrity ─────────────────────────────────────────────
171
+ // Ten keys, in this order:
172
+ // _dh SHA256(classes.dex of base.apk)
173
+ // _iln SHA256 of the sorted .so names the app unpacks under its data
174
+ // directory (files/decompressed/libs.spo); fixed per package, below
175
+ // _isb size of base.apk in bytes, as a string
176
+ // _ip package name
177
+ // did Build.ID + "." + Build.VERSION.INCREMENTAL
178
+ // _p ApplicationInfo.sourceDir, the installed base.apk path
179
+ // _ln SHA256 of the names in nativeLibraryDir; that directory is empty on
180
+ // current builds, so the app hashes its fallback string "signal_error"
181
+ // _ist SHA256(first 10 MiB of base.apk)
182
+ // _icr SHA1(DER signing certificate)
183
+ // _is SHA256(base.apk)
184
+ // Values are standard base64 with '/' written as '\/'. Returns null when the
185
+ // material predates these hashes, so the field is omitted instead of faked.
186
+ const GI_ILN = {
187
+ 'com.whatsapp': 'i7ls4vAnG2Bvmsp8NDKh7uREGcY4q8Hi4JtdCLix+Cc=',
188
+ 'com.whatsapp.w4b': 'Mw3rbAdtB9wUHE6w9vpff27e6I9/ruCa555vkJXh+N4='
189
+ };
190
+ const GI_LN = crypto.createHash('sha256').update('signal_error').digest('base64');
191
+
192
+ // install: { did, sourceDir } — the firmware string and the base.apk path of
193
+ // the install this session claims to be.
194
+ function generateGiToken(authKeyRaw, material, install) {
195
+ if (!material || !material.classesDexSha256 || material.apkSize == null ||
196
+ !material.apkSha256Full || !material.apkSha256First10M ||
197
+ !material.certificates || !material.certificates.length ||
198
+ !install || !install.did || !install.sourceDir) {
199
+ return null;
200
+ }
201
+ const pkg = material.packageName || 'com.whatsapp';
202
+ const iln = GI_ILN[pkg];
203
+ if (!iln) return null;
204
+
205
+ const esc = (s) => String(s).replace(/\//g, '\\/');
206
+ const b64 = (buf) => esc(buf.toString('base64'));
207
+
208
+ const json = '{' +
209
+ '"_dh":"' + b64(material.classesDexSha256) + '",' +
210
+ '"_iln":"' + esc(iln) + '",' +
211
+ '"_isb":"' + String(material.apkSize) + '",' +
212
+ '"_ip":"' + esc(pkg) + '",' +
213
+ '"did":"' + esc(install.did) + '",' +
214
+ '"_p":"' + esc(install.sourceDir) + '",' +
215
+ '"_ln":"' + esc(GI_LN) + '",' +
216
+ '"_ist":"' + b64(material.apkSha256First10M) + '",' +
217
+ '"_icr":"' + b64(crypto.createHash('sha1').update(material.certificates[0]).digest()) + '",' +
218
+ '"_is":"' + b64(material.apkSha256Full) + '"' +
219
+ '}';
220
+ return integrityEnvelope(authKeyRaw, json);
221
+ }
222
+
123
223
  // ─── Empty (NONE) fallbacks ───────────────────────────────────────────────────
124
224
 
125
225
  const EMPTY_ANDROID = { gpia: '', gg: '', gi: '', gp: '', ge: '', ga: '' };
@@ -268,13 +368,23 @@ async function attestationFields(device, nonceB64, opts) {
268
368
  const a = await androidIntegrity(nonceB64 || '', device);
269
369
  const gs = generateGsToken();
270
370
  const ge = generateGeToken();
271
- const ga = generateGaToken(nonceB64 || '');
371
+ // The envelope key comes from the Noise static public key; a random one
372
+ // still yields a well-formed field when the caller has no key to give.
373
+ const authKeyRaw = (Buffer.isBuffer(opts.authKeyRaw) && opts.authKeyRaw.length === 32)
374
+ ? opts.authKeyRaw
375
+ : crypto.randomBytes(32);
376
+ const ga = opts.ga || generateGaToken(authKeyRaw);
377
+ const gg = a.gpia
378
+ ? generateGgToken(authKeyRaw, 0, a.gpia)
379
+ : generateGgToken(authKeyRaw, 1003, 'undefined');
380
+ const gp = generateGpToken(opts.apkMaterial || null);
381
+ const gi = generateGiToken(authKeyRaw, opts.apkMaterial || null, opts.install || null);
272
382
  _ok('_gs field generated (WASafe envelope, ' + gs.length + ' encoded chars)');
273
383
  return [
274
384
  'gpia', orNull(a.gpia),
275
- '_gg', orNull(a.gg),
276
- '_gi', orNull(a.gi),
277
- '_gp', orNull(a.gp),
385
+ '_gg', gg,
386
+ '_gi', gi,
387
+ '_gp', gp,
278
388
  '_ge', ge,
279
389
  '_ga', ga,
280
390
  '_gs', gs,
@@ -311,6 +421,7 @@ module.exports = {
311
421
  attestationFields,
312
422
  attestBody,
313
423
  androidInfo,
424
+ generateGaToken,
314
425
  toHex,
315
426
  toBase64Url,
316
427
  EMPTY_ANDROID,
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
- //registration tool
2
+ //registration tool//
3
3
  const crypto = require('crypto');
4
4
  const https = require('https');
5
5
  const tls = require('tls');
6
+ const zlib = require('zlib');
6
7
  const fs = require('fs');
7
8
  const os = require('os');
8
9
  const path = require('path');
@@ -40,13 +41,18 @@ function _uaPlatformName(device) {
40
41
 
41
42
  function registrationHeaders(device, waVersion) {
42
43
  if (device.os === 'android') {
44
+ // Header set and order mirror the native client's registration POST: no
45
+ // Accept, Connection kept alive, gzip advertised (the response is gunzipped
46
+ // on both the direct and SOCKS paths). request_token is an UPPERCASE UUID —
47
+ // the native client never mints a lowercase one.
43
48
  return {
44
- 'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
45
- 'Content-Type': 'application/x-www-form-urlencoded',
46
- 'Accept': 'text/json',
47
- 'WaMsysRequest': '1',
49
+ 'User-Agent': `WhatsApp/${waVersion} ${_uaPlatformName(device)}/${device.osVersion} Device/${device.manufacturer}-${device.modelId}`,
50
+ 'WaMsysRequest': '1',
48
51
  // A fresh one per request, the way the native client mints it.
49
- 'request_token': uuidv4()
52
+ 'request_token': uuidv4().toUpperCase(),
53
+ 'Content-Type': 'application/x-www-form-urlencoded',
54
+ 'Connection': 'Keep-Alive',
55
+ 'Accept-Encoding': 'gzip'
50
56
  };
51
57
  }
52
58
  return {
@@ -66,7 +72,7 @@ const {
66
72
  parseSocksProxy, socksProxyUrl, socksConnect, proxyAgent
67
73
  } = require('./socks');
68
74
 
69
- async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device) {
75
+ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, device, query) {
70
76
  const dHost = 'v.whatsapp.net';
71
77
  const dPort = 443;
72
78
 
@@ -88,23 +94,27 @@ async function httpPostViaSocks(path, body, waVersion, proxyUrl, authHeader, dev
88
94
  });
89
95
 
90
96
  const headers = registrationHeaders(device || getDeviceConfig(), waVersion);
97
+ // The native client submits the parameters twice: once in the clear as the
98
+ // URL query, once encrypted inside ENC. The query string here is the exact
99
+ // plaintext that was encrypted, byte for byte.
100
+ const target = query ? `/v2${path}?${query}` : `/v2${path}`;
91
101
  const reqLines = [
92
- `POST /v2${path} HTTP/1.1`,
102
+ `POST ${target} HTTP/1.1`,
93
103
  `Host: ${dHost}`,
94
104
  ...Object.keys(headers).map(k => `${k}: ${headers[k]}`),
95
105
  `Content-Length: ${Buffer.byteLength(body)}`
96
106
  ];
97
107
  if (authHeader) reqLines.push(`Authorization: ${authHeader}`);
98
- // Asked for, but not relied on: the server answers and then keeps the
99
- // connection open anyway. The response is finished by its own length, not by
100
- // the socket closing.
101
- reqLines.push(`Connection: close`, '', body);
108
+ // Connection: Keep-Alive rides in from registrationHeaders, matching the
109
+ // native client; the response is still delimited by its own length and the
110
+ // socket is destroyed once it is read.
111
+ reqLines.push('', body);
102
112
  const req = reqLines.join('\r\n');
103
113
 
104
114
  tlsSocket.write(req);
105
115
 
106
- const { status, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
107
- const bodyStr = bodyBuf.toString('utf8');
116
+ const { status, headers: resHeaders, body: bodyBuf } = await readHttpResponse(tlsSocket, path);
117
+ const bodyStr = decodeBody(bodyBuf, resHeaders).toString('utf8');
108
118
 
109
119
  if (status !== 200) throw new Error(`HTTP ${status} ${path}: ${bodyStr}`);
110
120
  try { return JSON.parse(bodyStr); } catch (_) { return { raw: bodyStr }; }
@@ -172,6 +182,17 @@ function readHttpResponse(socket, label, timeoutMs) {
172
182
  });
173
183
  }
174
184
 
185
+ // The registration POST advertises `Accept-Encoding: gzip`, so the server may
186
+ // answer gzip-compressed. Decode by the response's own content-encoding; fall
187
+ // back to the raw bytes if the header lies or the stream will not inflate, so a
188
+ // plain reply still parses. `headers` is the raw header block (any case).
189
+ function decodeBody(bodyBuf, headers) {
190
+ if (/^content-encoding:[ \t]*gzip/im.test(headers || '')) {
191
+ try { return zlib.gunzipSync(bodyBuf); } catch (_) { /* fall through */ }
192
+ }
193
+ return bodyBuf;
194
+ }
195
+
175
196
  /**
176
197
  * Parse what has arrived so far.
177
198
  *
@@ -1221,6 +1242,61 @@ function toUrlHex(buf) {
1221
1242
  // IMPORTANT: do NOT use URLSearchParams here — it would double-encode the
1222
1243
  // percent-hex sequences in the 'id' field (%AA → %25AA).
1223
1244
 
1245
+ // The native client inserts registration fields into a LinkedHashMap in a
1246
+ // fixed order; the wire sequence is that insertion order, not a sort. These are
1247
+ // the live orders captured from WhatsApp Android (WA_REVERSE_2026): 50 fields
1248
+ // for /v2/code, 49 for /v2/register. gpia/token/vname that this build omits
1249
+ // simply drop out — their slots collapse.
1250
+ const CODE_PARAM_ORDER = [
1251
+ 'method', 'backup_token', '_gs', 'sim_mnc', 'id', 'mnc', '_gg',
1252
+ 'network_radio_type', 'lg', 'rc', 'pid', '_ge', 'cellular_strength', 'gpia',
1253
+ 'hasinrc', 'aid', 'mcc', '_gp', 'hasav', 'device_ram', 'db', 'sim_type',
1254
+ 'prefer_sms_over_flash', 'access_session_id', 'sim_mcc', 'roaming_type',
1255
+ 'mistyped', 'airplane_mode_type', 'e_ident', 'e_skey_sig', 'entrypoint',
1256
+ 'token', 'in', 'expid', 'simnum', 'cc', 'education_screen_displayed',
1257
+ 'e_skey_val', 'authkey', 'e_keytype', 'e_regid', 'client_metrics', '_gi',
1258
+ 'e_skey_id', 'feo2_query_status', 'reason', 'fdid', 'recaptcha', '_ga', 'lc'
1259
+ ];
1260
+
1261
+ const REGISTER_PARAM_ORDER = [
1262
+ 'code', 'backup_token', 'sim_mnc', '_gs', 'id', 'device_ram', 'db', '_gg',
1263
+ 'sim_operator_name', 'passkey_login_status', 'lg', 'rc', 'network_radio_type',
1264
+ 'pid', '_ge', 'cellular_strength', 'gpia', 'hasinrc', 'aid',
1265
+ 'network_operator_name', 'mcc', '_gp', 'mnc', 'entered', 'access_session_id',
1266
+ 'sim_mcc', 'roaming_type', 'mistyped', 'airplane_mode_type', 'has_play_store',
1267
+ 'sim_type', 'e_ident', 'e_skey_sig', 'entrypoint', 'in', 'expid', 'simnum',
1268
+ 'cc', 'e_skey_val', 'authkey', 'e_keytype', 'e_regid', '_gi', 'client_metrics',
1269
+ 'e_skey_id', 'fdid', 'recaptcha', '_ga', 'lc'
1270
+ ];
1271
+
1272
+ // Reorder a flat [k, v, k, v, …] list to the native insertion order for the
1273
+ // endpoint. Keys outside the reference order (push_token and any build-specific
1274
+ // extras) keep their relative order and trail the known fields, so nothing is
1275
+ // dropped. Only /v2/code and /v2/register have a reference order; every other
1276
+ // endpoint (client_log, exist, …) and every non-Android device passes through
1277
+ // untouched.
1278
+ function reorderRegistrationPairs(pairs, path, device) {
1279
+ const order = path === '/code' ? CODE_PARAM_ORDER
1280
+ : path === '/register' ? REGISTER_PARAM_ORDER
1281
+ : null;
1282
+ if (!order || !device || device.os !== 'android') return pairs;
1283
+
1284
+ const orderSet = new Set(order);
1285
+ const seen = new Map();
1286
+ const extras = [];
1287
+ for (let i = 0; i < pairs.length; i += 2) {
1288
+ const k = pairs[i];
1289
+ if (seen.has(k)) continue;
1290
+ seen.set(k, pairs[i + 1]);
1291
+ if (!orderSet.has(k)) extras.push(k);
1292
+ }
1293
+
1294
+ const out = [];
1295
+ for (const k of order) if (seen.has(k)) out.push(k, seen.get(k));
1296
+ for (const k of extras) out.push(k, seen.get(k));
1297
+ return out;
1298
+ }
1299
+
1224
1300
  function buildForm(pairs, extraPairs) {
1225
1301
  const parts = [];
1226
1302
  for (let i = 0; i < pairs.length; i += 2) {
@@ -1339,15 +1415,59 @@ function getAppPid(store) {
1339
1415
  return store._appPid;
1340
1416
  }
1341
1417
 
1342
- // is_sim_absent used to be the constant false while the same request could send
1343
- // sim_mcc/sim_mnc as 000/000 — which is precisely what a handset reports when
1344
- // there is no SIM in it. One request then said both "a SIM is present" and "no
1345
- // operator", which no real handset ever says. Reading it off the operator the
1346
- // request actually declares keeps the two halves telling one story.
1347
- function buildClientMetrics(attempt, meta) {
1418
+ // ---------- the install _gi describes ----------
1419
+ //
1420
+ // sourceDir is where Android put base.apk: two random 16-byte tags, each
1421
+ // URL-safe base64 with padding, as PackageManager names them. did is the
1422
+ // firmware: Build.ID, a dot, Build.VERSION.INCREMENTAL. Samsung's incremental
1423
+ // is the model without "SM-", region XX, update type U, bootloader revision,
1424
+ // major release, year (X = 2024) and month letters and a build digit; other
1425
+ // vendors are given a numeric one. Both belong to one install, so they come
1426
+ // from the session identity like pid and stay put across /code and /register.
1427
+ function getInstallIdentity(store, device, packageName) {
1428
+ const seed = (store.identityId && Buffer.isBuffer(store.identityId))
1429
+ ? store.identityId
1430
+ : Buffer.from(String(store.phoneNumber || ''), 'utf8');
1431
+ const h = (label) => crypto.createHash('sha256').update(seed).update(label).digest();
1432
+ const tag = (label) => h(label).subarray(0, 16).toString('base64')
1433
+ .replace(/\+/g, '-').replace(/\//g, '_');
1434
+
1435
+ const sourceDir = '/data/app/~~' + tag('apkdir') + '/' +
1436
+ packageName + '-' + tag('pkgdir') + '/base.apk';
1437
+
1438
+ let incremental = device && device.osBuildIncremental;
1439
+ if (!incremental) {
1440
+ const f = h('firmware');
1441
+ const model = String((device && device.modelId) || '');
1442
+ if (/^SM-/i.test(model)) {
1443
+ incremental = model.replace(/^SM-/i, '').toUpperCase() + 'XXU' +
1444
+ String(1 + f[0] % 3) +
1445
+ 'ABC'[f[1] % 3] +
1446
+ 'X' +
1447
+ 'ABCDEFGHIJKL'[f[2] % 12] +
1448
+ String(1 + f[3] % 9);
1449
+ } else {
1450
+ incremental = String(10000000 + f.readUInt32BE(4) % 90000000);
1451
+ }
1452
+ }
1453
+ const buildId = (device && device.osBuildNumber) || 'UP1A.231005.007';
1454
+
1455
+ return { did: buildId + '.' + incremental, sourceDir };
1456
+ }
1457
+
1458
+ // Whether the request describes a phone with no SIM in it. The operator code
1459
+ // is the tell: a real mcc means a card is present, a missing or all-zero mcc
1460
+ // means none. Every SIM-shaped field (is_sim_absent here, sim_type/simnum and
1461
+ // the operator fields in getRequestVerificationCodeParameters) reads it off
1462
+ // this one test, so a request never says a SIM is both present and absent.
1463
+ function _simAbsent(meta) {
1348
1464
  const mcc = meta && meta.mcc != null ? String(meta.mcc).trim() : '';
1349
- const simAbsent = !/^\d+$/.test(mcc) || /^0+$/.test(mcc);
1350
- const json = '{"attempts":' + (attempt || 1)
1465
+ return !/^\d+$/.test(mcc) || /^0+$/.test(mcc);
1466
+ }
1467
+
1468
+ function buildClientMetrics(attempt, meta) {
1469
+ const simAbsent = _simAbsent(meta);
1470
+ const json = '{"attempts":' + (attempt == null ? 1 : attempt)
1351
1471
  + ',"app_campaign_download_source":"google-play|unknown"'
1352
1472
  + ',"is_sim_absent":' + (simAbsent ? 'true' : 'false') + '}';
1353
1473
  return encodeURIComponent(json);
@@ -1382,20 +1502,30 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
1382
1502
  // otherwise gets the request answered with an SMS — or with no route at
1383
1503
  // all. They stay at their headless-safe values for every other method.
1384
1504
  const wantsFlash = method === 'flash';
1505
+ // One SIM story across every field. The operator codes decide it: a real
1506
+ // mcc means a SIM is in the phone, so sim_type and simnum say "present" and
1507
+ // the four operator fields carry the home network. A missing or all-zero
1508
+ // mcc is a phone with no SIM, which reports "000" in all four and both SIM
1509
+ // flags at 0 — the shape a device on Wi-Fi without a card actually sends.
1510
+ // buildClientMetrics reads is_sim_absent off the same test, so the request
1511
+ // never says a SIM is both there and not.
1512
+ const simAbsent = _simAbsent(meta);
1513
+ const simMcc = simAbsent ? '000' : meta.mcc;
1514
+ const simMnc = simAbsent ? '000' : meta.mnc;
1385
1515
  return [
1386
1516
  'method', method,
1387
- 'sim_mcc', meta.mcc,
1388
- 'sim_mnc', meta.mnc,
1517
+ 'sim_mcc', simMcc,
1518
+ 'sim_mnc', simMnc,
1389
1519
  'reason', '',
1390
- 'mcc', meta.mcc,
1391
- 'mnc', meta.mnc,
1520
+ 'mcc', simMcc,
1521
+ 'mnc', simMnc,
1392
1522
  'feo2_query_status', 'success_get_client_capabilities',
1393
1523
  'db', '1',
1394
- 'sim_type', '1',
1524
+ 'sim_type', simAbsent ? '0' : '1',
1395
1525
  'recaptcha', '%7B%22stage%22%3A%22ABPROP_DISABLED%22%7D',
1396
1526
  'network_radio_type', '1',
1397
1527
  'prefer_sms_over_flash', wantsFlash ? 'true' : 'false',
1398
- 'simnum', '0',
1528
+ 'simnum', simAbsent ? '0' : '1',
1399
1529
  'airplane_mode_type', '0',
1400
1530
  'client_metrics', buildClientMetrics(attempt, meta),
1401
1531
  'mistyped', '7',
@@ -1431,6 +1561,31 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
1431
1561
  ];
1432
1562
  }
1433
1563
 
1564
+ // ---------- /register device parameters (Android) ----------
1565
+ //
1566
+ // The Android app sends its device description again on /register, with the
1567
+ // values it sent on /code, so they are taken from the /code list rather than
1568
+ // restated. client_metrics is rebuilt: its attempt counter restarts at 0 on
1569
+ // this screen.
1570
+ const REGISTER_DEVICE_FIELDS = new Set([
1571
+ 'backup_token', 'sim_mnc', 'device_ram', 'db', 'network_radio_type', 'pid',
1572
+ 'cellular_strength', 'hasinrc', 'aid', 'mcc', 'mnc', 'sim_mcc',
1573
+ 'roaming_type', 'mistyped', 'airplane_mode_type', 'sim_type', 'simnum',
1574
+ 'recaptcha'
1575
+ ]);
1576
+
1577
+ function getRegisterDeviceParameters(store, meta, device) {
1578
+ if (!device || device.os !== 'android') return [];
1579
+ const code = getRequestVerificationCodeParameters(
1580
+ store, store.codeMethod || 'sms', meta, device, 0);
1581
+ const out = [];
1582
+ for (let i = 0; i < code.length; i += 2) {
1583
+ if (REGISTER_DEVICE_FIELDS.has(code[i])) out.push(code[i], code[i + 1]);
1584
+ }
1585
+ out.push('client_metrics', buildClientMetrics(0, meta));
1586
+ return out;
1587
+ }
1588
+
1434
1589
  // ---------- Payload ----------
1435
1590
 
1436
1591
  // base64url WITH padding — matches Java's Base64.getUrlEncoder().encodeToString()
@@ -1506,7 +1661,7 @@ function buildVerifiedNameCertificate(store, device) {
1506
1661
  ]));
1507
1662
  }
1508
1663
 
1509
- async function buildPayload(store, waVersion, useToken, extraPairs) {
1664
+ async function buildPayload(store, waVersion, useToken, extraPairs, path) {
1510
1665
  const { cc, national } = parsePhone(store.phoneNumber);
1511
1666
  const meta = getCountryMeta(cc, store);
1512
1667
  const device = store.device || getDeviceConfig();
@@ -1537,9 +1692,21 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
1537
1692
  // were wired up.
1538
1693
  const pushToken = await pushClientFor(device).getPushToken(store, device);
1539
1694
 
1540
- const attestFields = await attestation.attestationFields(device, nonceB64, { pushToken });
1695
+ const authKeyRaw = stripKeyPrefix(store.noiseKeyPair.public);
1696
+ const apkMaterial = device.os === 'android' ? tryLoadAndroidMaterial(device) : null;
1697
+ // One _ga per session: the app sends the same one on /code and /register.
1698
+ if (device.os === 'android' && !store._gaToken) {
1699
+ store._gaToken = attestation.generateGaToken(authKeyRaw);
1700
+ }
1701
+ const attestFields = await attestation.attestationFields(device, nonceB64, {
1702
+ pushToken,
1703
+ authKeyRaw,
1704
+ apkMaterial,
1705
+ install: apkMaterial ? getInstallIdentity(store, device, apkMaterial.packageName) : null,
1706
+ ga: store._gaToken
1707
+ });
1541
1708
 
1542
- return buildForm([
1709
+ const basePairs = [
1543
1710
  'cc', cc,
1544
1711
  'in', national,
1545
1712
  'rc', String(RELEASE_CHANNEL),
@@ -1561,7 +1728,12 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
1561
1728
  'access_session_id', getAccessSessionId(store),
1562
1729
  'token', token,
1563
1730
  ...attestFields
1564
- ], extraPairs);
1731
+ ];
1732
+ const allPairs = extraPairs ? basePairs.concat(extraPairs) : basePairs;
1733
+ // Emit the fields in the native client's fixed insertion order for /code and
1734
+ // /register. The order is part of the wire fingerprint and, with the query
1735
+ // now mirrored into the URL, is visible to the server in the clear.
1736
+ return buildForm(reorderRegistrationPairs(allPairs, path, device));
1565
1737
  }
1566
1738
 
1567
1739
  // ---------- Encryption (AES-256-GCM, ephemeral X25519) ----------
@@ -1581,9 +1753,9 @@ function encryptPayload(plaintext) {
1581
1753
 
1582
1754
  // ---------- HTTP ----------
1583
1755
 
1584
- function httpPost(path, body, waVersion, authHeader, device) {
1756
+ function httpPost(path, body, waVersion, authHeader, device, query) {
1585
1757
  const proxy = socksProxyUrl();
1586
- if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device);
1758
+ if (proxy) return httpPostViaSocks(path, body, waVersion, proxy, authHeader, device, query);
1587
1759
 
1588
1760
  return new Promise((resolve, reject) => {
1589
1761
  const bodyBuf = Buffer.from(body, 'utf8');
@@ -1594,10 +1766,13 @@ function httpPost(path, body, waVersion, authHeader, device) {
1594
1766
  // Attestation cert-chain / App Attest header (Android cert chain, iOS
1595
1767
  // <attestation>|<keyId>). Only present when body attestation is available.
1596
1768
  if (authHeader) headers['Authorization'] = authHeader;
1769
+ // The native client submits the parameters twice: once in the clear as the
1770
+ // URL query, once encrypted inside ENC. The query is the exact ENC
1771
+ // plaintext, byte for byte.
1597
1772
  const opts = {
1598
1773
  hostname: 'v.whatsapp.net',
1599
1774
  port: 443,
1600
- path: '/v2' + path,
1775
+ path: query ? '/v2' + path + '?' + query : '/v2' + path,
1601
1776
  method: 'POST',
1602
1777
  timeout: 20000,
1603
1778
  headers
@@ -1606,7 +1781,12 @@ function httpPost(path, body, waVersion, authHeader, device) {
1606
1781
  const chunks = [];
1607
1782
  res.on('data', d => chunks.push(d));
1608
1783
  res.on('end', () => {
1609
- const text = Buffer.concat(chunks).toString('utf8');
1784
+ const encoding = String(res.headers['content-encoding'] || '');
1785
+ let buf = Buffer.concat(chunks);
1786
+ if (/gzip/i.test(encoding)) {
1787
+ try { buf = zlib.gunzipSync(buf); } catch (_) { /* keep raw */ }
1788
+ }
1789
+ const text = buf.toString('utf8');
1610
1790
  if (res.statusCode !== 200) {
1611
1791
  return reject(new Error(`HTTP ${res.statusCode} ${path}: ${text}`));
1612
1792
  }
@@ -1622,7 +1802,7 @@ function httpPost(path, body, waVersion, authHeader, device) {
1622
1802
  }
1623
1803
 
1624
1804
  async function sendRequest(path, store, waVersion, useToken, extraPairs) {
1625
- const plaintext = await buildPayload(store, waVersion, useToken, extraPairs);
1805
+ const plaintext = await buildPayload(store, waVersion, useToken, extraPairs, path);
1626
1806
  return sendEncrypted(path, plaintext, store, waVersion);
1627
1807
  }
1628
1808
 
@@ -1650,7 +1830,14 @@ async function sendEncrypted(path, plaintext, store, waVersion) {
1650
1830
 
1651
1831
  _whaDbg('[DBG] REG → ' + path + ' (' + body.length + ' bytes' +
1652
1832
  (bodyAtt.bodyAttestation ? ', attested' : '') + ')');
1653
- const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device);
1833
+ // The ENC plaintext is also the URL query: the native client submits every
1834
+ // parameter twice, in the clear and encrypted, byte for byte identical. This
1835
+ // is documented and verified only for the Android /code and /register
1836
+ // requests, so the mirror is scoped to them; iOS and the funnel endpoints
1837
+ // keep posting to a bare path, exactly as before.
1838
+ const mirrorQuery = device.os === 'android' && (path === '/code' || path === '/register');
1839
+ const result = await httpPost(path, body, waVersion, bodyAtt.authorizationHeader, device,
1840
+ mirrorQuery ? plaintext : undefined);
1654
1841
  _whaDbg('[DBG] REG ← ' + path + ' ' +
1655
1842
  (result && result.status ? result.status : JSON.stringify(result).slice(0, 120)) +
1656
1843
  (result && result.reason ? ' reason=' + result.reason : ''));
@@ -2403,14 +2590,17 @@ async function verifyCode(store, code, opts) {
2403
2590
  const screen = currentVerifyScreen(store);
2404
2591
  await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
2405
2592
 
2406
- const result = await sendRequest('/register', store, waVersion, true, [
2593
+ // The Android app sends the setup token on /code only; iOS keeps it here.
2594
+ const registerMeta = getCountryMeta(parsePhone(store.phoneNumber).cc, store);
2595
+ const result = await sendRequest('/register', store, waVersion, _device.os !== 'android', [
2407
2596
  'code', normalized,
2408
2597
  'sim_operator_name', '',
2409
2598
  'passkey_login_status', 'not_attempted',
2410
2599
  'network_operator_name', '',
2411
2600
  'entered', '1',
2412
2601
  'has_play_store', 'true',
2413
- 'entrypoint', 'suma'
2602
+ 'entrypoint', 'suma',
2603
+ ...getRegisterDeviceParameters(store, registerMeta, _device)
2414
2604
  ]);
2415
2605
 
2416
2606
  const status = result.status;
@@ -2494,7 +2684,7 @@ async function verifyCode(store, code, opts) {
2494
2684
  module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchPlayVersion, fetchAndroidVersionLive, fetchWaVersion, currentVersionFor, refreshSessionVersion, clearVersionCache, tryLoadAndroidMaterial, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
2495
2685
 
2496
2686
  // The HTTP reader, exposed for tests. Not part of the public API.
2497
- module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
2687
+ module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody, decodeBody, reorderRegistrationPairs };
2498
2688
 
2499
2689
  // Token computation, exposed for tests. Not part of the public API.
2500
2690
  module.exports._token = { computeToken, androidMaterialPath, registrationHeaders, ensureAndroidMaterial, deviceForRegistration, buildVerifiedNameCertificate, buildPayload };
@@ -2504,7 +2694,7 @@ module.exports._verify = {
2504
2694
  hasChallenge, is2FARequired, decodeOrNull, isSuccessful,
2505
2695
  normalizeCodeResult, currentVerifyScreen, adoptCanonicalNumber, funnelEnabled, waitHint,
2506
2696
  flashCodeFromCallerId, flashCodeLength, applyFlashFilter, codeForSubmission,
2507
- getRequestVerificationCodeParameters
2697
+ getRequestVerificationCodeParameters, getRegisterDeviceParameters, getInstallIdentity
2508
2698
  };
2509
2699
 
2510
2700
  // Pacing internals, exposed for tests. Not part of the public API.
package/lib/Store.js CHANGED
@@ -209,6 +209,11 @@ function storeToJson(store) {
209
209
  // call it has to know, since that code is read off the caller ID rather
210
210
  // than out of a message.
211
211
  codeMethod: store.codeMethod || null,
212
+ // /code and /register come from one running app, which sends the same
213
+ // access_session_id and the same _ga in both; the steps are usually
214
+ // separate commands, so both are written down rather than drawn again.
215
+ accessSessionId: store._accessSessionId || null,
216
+ gaToken: store._gaToken || null,
212
217
  // Firebase identity behind the push_token. Google issues an android id once
213
218
  // and expects it back; discarding it would mint a new phantom device on
214
219
  // every registration step, which is worse than carrying one.
@@ -266,6 +271,8 @@ function storeFromJson(obj) {
266
271
  simMnc: obj.simMnc || null,
267
272
  codePending: obj.codePending || false,
268
273
  codeMethod: obj.codeMethod || null,
274
+ _accessSessionId: obj.accessSessionId || undefined,
275
+ _gaToken: obj.gaToken || undefined,
269
276
  // Absent on stores written before push tokens existed; null simply means
270
277
  // the first registration step will fetch one.
271
278
  fcm: obj.fcm || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.33.23",
3
+ "version": "5.33.28",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",