whalibmob 5.29.2 → 5.29.4
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 +98 -6
- package/index.d.ts +85 -2
- package/lib/Client.js +321 -77
- package/lib/DeviceConfig.js +8 -1
- package/lib/DeviceManager.js +102 -36
- package/lib/MediaService.js +78 -13
- package/lib/Registration.js +82 -7
- package/lib/Store.js +12 -0
- package/lib/constants.js +15 -15
- package/lib/proto/MessageProto.js +72 -3
- package/package.json +1 -1
package/lib/DeviceManager.js
CHANGED
|
@@ -1206,57 +1206,123 @@ class DeviceManager {
|
|
|
1206
1206
|
* and sends both hashes on the same notification — so each is verified on its
|
|
1207
1207
|
* own, and one going wrong does not cost the other.
|
|
1208
1208
|
*
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1209
|
+
* The steps are applied one at a time, and each is checked against its own
|
|
1210
|
+
* hash: `device_hash` describes the list **after that change**, not after the
|
|
1211
|
+
* notification as a whole. A contact linking two devices in one sitting sends
|
|
1212
|
+
* two children with two different hashes, and reading them as one change with
|
|
1213
|
+
* one hash — which is what this did — meant the notification could never
|
|
1214
|
+
* verify and the cache was dropped every time. Stepping through them the way
|
|
1215
|
+
* whatsmeow's handleDeviceNotification does keeps the cache across exactly
|
|
1216
|
+
* the case that used to defeat it.
|
|
1217
|
+
*
|
|
1218
|
+
* A step that fails to verify drops the cache but not the running list, so a
|
|
1219
|
+
* later step whose hash does match puts a proven list back — the same
|
|
1220
|
+
* recovery whatsmeow gets from mutating its local copy after deleting the
|
|
1221
|
+
* cached one.
|
|
1222
|
+
*
|
|
1223
|
+
* @param {object} opts
|
|
1224
|
+
* @param {string} opts.priKey the cache entry: digits, or 'lid:<user>'
|
|
1225
|
+
* @param {string} opts.priUser the JID user part the ids belong to
|
|
1226
|
+
* @param {string} [opts.priServer] 's.whatsapp.net' (default) or 'lid'
|
|
1227
|
+
* @param {string} [opts.lidKey] cache entry for the LID half, when known
|
|
1228
|
+
* @param {string} [opts.lidUser] the LID user part
|
|
1229
|
+
* @param {Array} opts.steps from Client._parseDeviceDelta
|
|
1230
|
+
* @returns {{primaryOk: boolean, lidOk: boolean}} which halves ended verified
|
|
1215
1231
|
*/
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1232
|
+
applyDeviceDelta(opts) {
|
|
1233
|
+
opts = opts || {};
|
|
1234
|
+
const priKey = opts.priKey;
|
|
1235
|
+
const priUser = opts.priUser;
|
|
1236
|
+
const priServer = opts.priServer || 's.whatsapp.net';
|
|
1237
|
+
const steps = opts.steps;
|
|
1238
|
+
const out = { primaryOk: false, lidOk: false };
|
|
1239
|
+
|
|
1240
|
+
if (!priKey || !priUser) return out;
|
|
1241
|
+
if (!Array.isArray(steps) || !steps.length) return out;
|
|
1220
1242
|
|
|
1221
1243
|
// No baseline means there is nothing to apply a change to. Caching just the
|
|
1222
1244
|
// one device named in the notification would make a list of one look
|
|
1223
1245
|
// authoritative, which is how a recipient's other devices stop being
|
|
1224
1246
|
// written to.
|
|
1225
|
-
const cached = this._dcGet(
|
|
1226
|
-
if (!cached || !cached.length) return
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1247
|
+
const cached = this._dcGet(priKey);
|
|
1248
|
+
if (!cached || !cached.length) return out;
|
|
1249
|
+
|
|
1250
|
+
const haveLid = !!(opts.lidKey && opts.lidUser);
|
|
1251
|
+
const pri = new Set(cached);
|
|
1252
|
+
// The LID half starts from whatever is on file, empty included. It cannot
|
|
1253
|
+
// go wrong the way the primary half could: a list built from nothing has to
|
|
1254
|
+
// pass the server's hash before it is written anywhere.
|
|
1255
|
+
const lid = haveLid ? new Set(this._dcGet(opts.lidKey) || []) : null;
|
|
1256
|
+
|
|
1257
|
+
for (const step of steps) {
|
|
1258
|
+
if (!step) continue;
|
|
1259
|
+
|
|
1260
|
+
// `update` does not say what changed. The list on file cannot be trusted
|
|
1261
|
+
// and cannot be repaired, so it goes — and the remaining steps still get
|
|
1262
|
+
// their turn, as they do in whatsmeow.
|
|
1263
|
+
if (step.tag === 'update') {
|
|
1264
|
+
this._dcDel([priKey]);
|
|
1265
|
+
out.primaryOk = false;
|
|
1266
|
+
_whaDbg('[DBG] DEV_HASH update for ' + priKey + ' — cache dropped');
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (step.tag !== 'add' && step.tag !== 'remove') continue;
|
|
1270
|
+
if (!Number.isFinite(step.deviceId) || !step.hash) continue;
|
|
1271
|
+
|
|
1272
|
+
if (step.tag === 'add') pri.add(step.deviceId);
|
|
1273
|
+
else pri.delete(step.deviceId);
|
|
1274
|
+
|
|
1275
|
+
const ids = [...pri].sort((a, b) => a - b);
|
|
1276
|
+
const ours = this._phashOfDevices(priUser, ids, priServer);
|
|
1277
|
+
if (ours && ours === step.hash) {
|
|
1278
|
+
this._dcSet(priKey, ids);
|
|
1279
|
+
out.primaryOk = true;
|
|
1280
|
+
_whaDbg('[DBG] DEV_HASH verified for ' + priKey + ' ids=[' + ids.join(',') +
|
|
1281
|
+
'] hash=' + ours);
|
|
1282
|
+
} else {
|
|
1283
|
+
this._dcDel([priKey]);
|
|
1284
|
+
out.primaryOk = false;
|
|
1285
|
+
_whaDbg('[DBG] DEV_HASH mismatch for ' + priKey + ' ours=' + ours +
|
|
1286
|
+
' server=' + step.hash + ' — dropping cache');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// The LID half of the same change, checked on its own. The server hashes
|
|
1290
|
+
// the two lists separately and one going wrong does not cost the other.
|
|
1291
|
+
if (!haveLid || step.lidDeviceId === null || !step.lidHash) continue;
|
|
1292
|
+
|
|
1293
|
+
if (step.tag === 'add') lid.add(step.lidDeviceId);
|
|
1294
|
+
else lid.delete(step.lidDeviceId);
|
|
1295
|
+
|
|
1296
|
+
const lidIds = [...lid].sort((a, b) => a - b);
|
|
1297
|
+
const lidOurs = this._phashOfDevices(opts.lidUser, lidIds, 'lid');
|
|
1298
|
+
if (lidOurs && lidOurs === step.lidHash) {
|
|
1299
|
+
this._dcSet(opts.lidKey, lidIds);
|
|
1300
|
+
out.lidOk = true;
|
|
1301
|
+
_whaDbg('[DBG] DEV_HASH verified for ' + opts.lidKey + ' ids=[' +
|
|
1302
|
+
lidIds.join(',') + '] hash=' + lidOurs);
|
|
1303
|
+
} else {
|
|
1304
|
+
this._dcDel([opts.lidKey]);
|
|
1305
|
+
out.lidOk = false;
|
|
1306
|
+
_whaDbg('[DBG] DEV_HASH mismatch for ' + opts.lidKey + ' ours=' + lidOurs +
|
|
1307
|
+
' server=' + step.lidHash + ' — dropping cache');
|
|
1308
|
+
}
|
|
1234
1309
|
}
|
|
1235
1310
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1311
|
+
return out;
|
|
1312
|
+
}
|
|
1238
1313
|
|
|
1239
|
-
|
|
1314
|
+
// The participant hash of one user's device ids, or null when it cannot be
|
|
1315
|
+
// computed. Same function the group fan-out and the server both use, which is
|
|
1316
|
+
// the whole reason the comparison above means anything.
|
|
1317
|
+
_phashOfDevices(user, deviceIds, server) {
|
|
1240
1318
|
try {
|
|
1241
1319
|
// Lazily required: MessageSender requires this file, so taking it at the
|
|
1242
1320
|
// top would close the cycle before either module finishes loading.
|
|
1243
1321
|
const { computePhash } = require('./messages/MessageSender');
|
|
1244
|
-
|
|
1322
|
+
return computePhash(deviceIds.map(d => makeDeviceJid(user, d, server)));
|
|
1245
1323
|
} catch (_) {
|
|
1246
|
-
return
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
if (ours !== String(serverHash)) {
|
|
1250
|
-
_whaDbg('[DBG] DEV_HASH mismatch for ' + cacheKey + ' ours=' + ours +
|
|
1251
|
-
' server=' + serverHash + ' — dropping cache');
|
|
1252
|
-
this._dcDel([cacheKey]);
|
|
1253
|
-
return false;
|
|
1324
|
+
return null;
|
|
1254
1325
|
}
|
|
1255
|
-
|
|
1256
|
-
this._dcSet(cacheKey, ids);
|
|
1257
|
-
_whaDbg('[DBG] DEV_HASH verified for ' + cacheKey + ' ids=[' + ids.join(',') +
|
|
1258
|
-
'] hash=' + ours);
|
|
1259
|
-
return true;
|
|
1260
1326
|
}
|
|
1261
1327
|
|
|
1262
1328
|
clearCache(phones) {
|
package/lib/MediaService.js
CHANGED
|
@@ -229,10 +229,6 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
|
|
|
229
229
|
});
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
// opts.fileEncSha256, when given, is checked against the blob as it arrived.
|
|
233
|
-
// The MAC inside decryptMedia already proves the plaintext was not tampered
|
|
234
|
-
// with, but this catches a truncated or substituted download before spending
|
|
235
|
-
// the work on it, and tells the two failures apart.
|
|
236
232
|
// Where a message's media actually lives. Most carry an absolute `url`, but a
|
|
237
233
|
// message that arrived through history sync often has only the `directPath`,
|
|
238
234
|
// which is that URL with the CDN host taken off the front.
|
|
@@ -245,19 +241,72 @@ function resolveMediaUrl(url, directPath) {
|
|
|
245
241
|
return 'https://' + CDN_HOST + (p.startsWith('/') ? '' : '/') + p;
|
|
246
242
|
}
|
|
247
243
|
|
|
244
|
+
// Compare a digest against what the message said it should be. Returns null
|
|
245
|
+
// when it matches or when there is nothing to compare against, and the reason
|
|
246
|
+
// when it does not.
|
|
247
|
+
function _digestMismatch(actual, expected, what) {
|
|
248
|
+
if (!expected || !expected.length) return null;
|
|
249
|
+
const want = Buffer.from(expected);
|
|
250
|
+
// timingSafeEqual throws on a length mismatch rather than returning false.
|
|
251
|
+
if (want.length !== actual.length) return what + ' is the wrong length';
|
|
252
|
+
return crypto.timingSafeEqual(actual, want) ? null : what + ' mismatch';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Download one media file and decrypt it.
|
|
257
|
+
*
|
|
258
|
+
* Three things are checked, and all three are checked by default — the message
|
|
259
|
+
* says what the file should be, so there is no reason to take the CDN's word
|
|
260
|
+
* for it:
|
|
261
|
+
*
|
|
262
|
+
* fileEncSha256 the blob as it arrived off the CDN
|
|
263
|
+
* the MAC inside decryptMedia, the one that actually authenticates
|
|
264
|
+
* fileSha256 the plaintext, after decrypting
|
|
265
|
+
*
|
|
266
|
+
* The MAC is the guarantee — its key comes from the mediaKey, which only the
|
|
267
|
+
* sender and the recipients hold — and it was always checked. The two digests
|
|
268
|
+
* were not: fileEncSha256 only when the caller asked for it, and fileSha256
|
|
269
|
+
* never at all, though it was written on every upload. whatsmeow checks both on
|
|
270
|
+
* every download (ErrInvalidMediaSHA256), and now so does this.
|
|
271
|
+
*
|
|
272
|
+
* They tell failures apart, too. A truncated or substituted download is caught
|
|
273
|
+
* before any work is spent decrypting it, and a plaintext that decrypts but is
|
|
274
|
+
* not the file the message described is a different fault from a corrupt blob.
|
|
275
|
+
*
|
|
276
|
+
* @param {object} [opts]
|
|
277
|
+
* @param {Buffer} [opts.fileEncSha256] digest of the encrypted blob
|
|
278
|
+
* @param {Buffer} [opts.fileSha256] digest of the decrypted file
|
|
279
|
+
* @param {boolean} [opts.verify=true] pass false to skip both digest checks;
|
|
280
|
+
* the MAC is always checked either way
|
|
281
|
+
*/
|
|
248
282
|
async function downloadMedia(url, mediaKey, keyName, opts) {
|
|
283
|
+
opts = opts || {};
|
|
284
|
+
const check = opts.verify !== false;
|
|
249
285
|
const encrypted = await _httpGet(url, opts);
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
throw new Error('downloadMedia: the downloaded file does not match the
|
|
257
|
-
'(
|
|
286
|
+
|
|
287
|
+
if (check) {
|
|
288
|
+
const bad = _digestMismatch(
|
|
289
|
+
crypto.createHash('sha256').update(encrypted).digest(),
|
|
290
|
+
opts.fileEncSha256, 'fileEncSha256');
|
|
291
|
+
if (bad) {
|
|
292
|
+
throw new Error('downloadMedia: the downloaded file does not match the ' +
|
|
293
|
+
'message (' + bad + ')');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const plaintext = decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
|
|
298
|
+
|
|
299
|
+
if (check) {
|
|
300
|
+
const bad = _digestMismatch(
|
|
301
|
+
crypto.createHash('sha256').update(plaintext).digest(),
|
|
302
|
+
opts.fileSha256, 'fileSha256');
|
|
303
|
+
if (bad) {
|
|
304
|
+
throw new Error('downloadMedia: the decrypted file is not the one the ' +
|
|
305
|
+
'message described (' + bad + ')');
|
|
258
306
|
}
|
|
259
307
|
}
|
|
260
|
-
|
|
308
|
+
|
|
309
|
+
return plaintext;
|
|
261
310
|
}
|
|
262
311
|
|
|
263
312
|
// The CDN applies the same Origin check on the way down as on the way up, so a
|
|
@@ -307,11 +356,26 @@ function _httpGet(url, opts, depth) {
|
|
|
307
356
|
});
|
|
308
357
|
}
|
|
309
358
|
|
|
359
|
+
// A thumbnail is encrypted under a key name of its own, not under the key name
|
|
360
|
+
// of the message that carries it. The one in use is the link-preview thumbnail
|
|
361
|
+
// on an extendedTextMessage — it has its own directPath and its own digests
|
|
362
|
+
// beside the message's, and reading it with 'WhatsApp Image Keys' produces a
|
|
363
|
+
// MAC failure and nothing else. There is no upload path because nothing here
|
|
364
|
+
// uploads one; they arrive with a link preview the sender built.
|
|
365
|
+
const THUMBNAIL_KEY_NAME = {
|
|
366
|
+
extendedText: 'WhatsApp Link Thumbnail Keys',
|
|
367
|
+
text: 'WhatsApp Link Thumbnail Keys'
|
|
368
|
+
};
|
|
369
|
+
|
|
310
370
|
function getMediaKeyName(mediaType) {
|
|
311
371
|
const typeInfo = MEDIA_PATH[mediaType];
|
|
312
372
|
return typeInfo ? typeInfo.keyName : null;
|
|
313
373
|
}
|
|
314
374
|
|
|
375
|
+
function getThumbnailKeyName(mediaType) {
|
|
376
|
+
return THUMBNAIL_KEY_NAME[mediaType] || null;
|
|
377
|
+
}
|
|
378
|
+
|
|
315
379
|
module.exports = {
|
|
316
380
|
MEDIA_PATH,
|
|
317
381
|
encryptMedia,
|
|
@@ -323,5 +387,6 @@ module.exports = {
|
|
|
323
387
|
httpGetBuffer: _httpGet,
|
|
324
388
|
deriveMediaKeyData,
|
|
325
389
|
getMediaKeyName,
|
|
390
|
+
getThumbnailKeyName,
|
|
326
391
|
resolveMediaUrl
|
|
327
392
|
};
|
package/lib/Registration.js
CHANGED
|
@@ -478,8 +478,32 @@ const COUNTRY_META = {
|
|
|
478
478
|
'998': { mcc: '434', mnc: '03', lg: 'ru', lc: 'UZ' }, // Uzbekistan (UzMobile)
|
|
479
479
|
};
|
|
480
480
|
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
// The carrier metadata for a country calling code.
|
|
482
|
+
//
|
|
483
|
+
// The table names one operator per country — Romania is always Orange, Brazil
|
|
484
|
+
// always Vivo — because that is the best a table keyed by calling code can do.
|
|
485
|
+
// It cannot be better: number portability means the prefix stopped naming the
|
|
486
|
+
// operator years ago, so guessing from the number would be wrong more often
|
|
487
|
+
// than the table is.
|
|
488
|
+
//
|
|
489
|
+
// So the caller can say instead. Whoever is registering knows which SIM is in
|
|
490
|
+
// the phone, and sim_mcc/sim_mnc are meant to describe that SIM; WA_SIM_MCC and
|
|
491
|
+
// WA_SIM_MNC (or `simMcc`/`simMnc` in the options) override the guess with the
|
|
492
|
+
// truth. The language and locale stay with the country either way.
|
|
493
|
+
//
|
|
494
|
+
// MNC keeps whatever width it is given: 226/10 and 226/010 are different
|
|
495
|
+
// networks to the server, and normalising them would break one of the two.
|
|
496
|
+
function getCountryMeta(cc, opts) {
|
|
497
|
+
const base = COUNTRY_META[cc] || { mcc: '000', mnc: '000', lg: 'en', lc: 'US' };
|
|
498
|
+
|
|
499
|
+
const mcc = (opts && opts.simMcc) || process.env.WA_SIM_MCC;
|
|
500
|
+
const mnc = (opts && opts.simMnc) || process.env.WA_SIM_MNC;
|
|
501
|
+
if (!mcc && !mnc) return base;
|
|
502
|
+
|
|
503
|
+
const out = Object.assign({}, base);
|
|
504
|
+
if (mcc) out.mcc = String(mcc).trim();
|
|
505
|
+
if (mnc) out.mnc = String(mnc).trim();
|
|
506
|
+
return out;
|
|
483
507
|
}
|
|
484
508
|
|
|
485
509
|
// ---------- Phone number parsing ----------
|
|
@@ -1201,6 +1225,57 @@ function waitHint(response, method) {
|
|
|
1201
1225
|
return retry > 0 ? retry : null;
|
|
1202
1226
|
}
|
|
1203
1227
|
|
|
1228
|
+
// ---------- device_ram ----------
|
|
1229
|
+
//
|
|
1230
|
+
// What the handset reports as usable RAM, in GiB, from
|
|
1231
|
+
// ActivityManager.MemoryInfo.totalMem — which is always below the nominal
|
|
1232
|
+
// figure on the box, because the kernel, the GPU and the secure world reserve a
|
|
1233
|
+
// slice before Android ever sees it. A 12 GB phone reports around 11.5.
|
|
1234
|
+
//
|
|
1235
|
+
// This was the constant 3.57 on every request, while the same payload declared
|
|
1236
|
+
// a Galaxy S24 Ultra beside it. There is no such handset: the S24 Ultra ships
|
|
1237
|
+
// with 12 GB and no variant of it has ever had 3.57. Two fields of one request
|
|
1238
|
+
// describing two different phones is not a fingerprint worth defending — it is
|
|
1239
|
+
// simply wrong, in the way a wrong `os_version` would be. It now comes off the
|
|
1240
|
+
// declared profile, so whichever handset the caller says they are on, the
|
|
1241
|
+
// memory belongs to it.
|
|
1242
|
+
const DEFAULT_DEVICE_RAM = '5.62';
|
|
1243
|
+
|
|
1244
|
+
function deviceRam(device) {
|
|
1245
|
+
const ram = device && device.ram;
|
|
1246
|
+
if (ram == null) return DEFAULT_DEVICE_RAM;
|
|
1247
|
+
const n = Number(ram);
|
|
1248
|
+
// Two decimals, the way the client formats it.
|
|
1249
|
+
return Number.isFinite(n) && n > 0 ? n.toFixed(2) : DEFAULT_DEVICE_RAM;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ---------- pid ----------
|
|
1253
|
+
//
|
|
1254
|
+
// The process id of the app making the request.
|
|
1255
|
+
//
|
|
1256
|
+
// This sent `process.pid` — the Node process. Inside a container that is 1, and
|
|
1257
|
+
// pid 1 on Android is `init`; no application process is ever 1. Elsewhere it is
|
|
1258
|
+
// whatever the host happened to hand out, which is as likely to be 7 as 24000.
|
|
1259
|
+
//
|
|
1260
|
+
// Android hands out pids from a counter that starts near 300 and wraps at
|
|
1261
|
+
// /proc/sys/kernel/pid_max, 32768 by default, so an app launched on a phone
|
|
1262
|
+
// that has been up any length of time lands well inside that range. One is
|
|
1263
|
+
// derived per store here, from the same identity material everything else in
|
|
1264
|
+
// the payload comes from — deterministic on purpose, because /exist, /code and
|
|
1265
|
+
// /register are three requests from one running app and a process does not
|
|
1266
|
+
// change its pid between them. Restoring a saved session keeps the pid it had,
|
|
1267
|
+
// for the same reason.
|
|
1268
|
+
function getAppPid(store) {
|
|
1269
|
+
if (store._appPid) return store._appPid;
|
|
1270
|
+
const seed = (store.identityId && Buffer.isBuffer(store.identityId))
|
|
1271
|
+
? store.identityId
|
|
1272
|
+
: Buffer.from(String(store.phoneNumber || ''), 'utf8');
|
|
1273
|
+
const h = crypto.createHash('sha256').update(seed).update('pid').digest();
|
|
1274
|
+
// 1024..32767 — clear of the kernel and early-boot range, inside pid_max.
|
|
1275
|
+
store._appPid = String(1024 + (h.readUInt32BE(0) % (32768 - 1024)));
|
|
1276
|
+
return store._appPid;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1204
1279
|
function buildClientMetrics(attempt) {
|
|
1205
1280
|
const json = '{"attempts":' + (attempt || 1)
|
|
1206
1281
|
+ ',"app_campaign_download_source":"google-play|unknown"'
|
|
@@ -1250,9 +1325,9 @@ function getRequestVerificationCodeParameters(store, method, meta, device, attem
|
|
|
1250
1325
|
'advertising_id', store.advertisingId || '',
|
|
1251
1326
|
'hasinrc', '1',
|
|
1252
1327
|
'roaming_type', '0',
|
|
1253
|
-
'device_ram',
|
|
1328
|
+
'device_ram', deviceRam(device),
|
|
1254
1329
|
'education_screen_displayed', 'true',
|
|
1255
|
-
'pid',
|
|
1330
|
+
'pid', getAppPid(store),
|
|
1256
1331
|
'cellular_strength', '5',
|
|
1257
1332
|
'backup_token', store.backupToken ? toUrlHex(store.backupToken) : '',
|
|
1258
1333
|
'tos_version', '5',
|
|
@@ -1354,7 +1429,7 @@ function buildVerifiedNameCertificate(store, device) {
|
|
|
1354
1429
|
|
|
1355
1430
|
async function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
1356
1431
|
const { cc, national } = parsePhone(store.phoneNumber);
|
|
1357
|
-
const meta = getCountryMeta(cc);
|
|
1432
|
+
const meta = getCountryMeta(cc, store);
|
|
1358
1433
|
const device = store.device || getDeviceConfig();
|
|
1359
1434
|
const token = useToken ? computeToken(waVersion, national, device) : null;
|
|
1360
1435
|
// Android formats fdid lowercase, iOS uppercase (matches the native clients).
|
|
@@ -1826,7 +1901,7 @@ async function checkNumberStatus(phoneNumber) {
|
|
|
1826
1901
|
const store = createNewStore(phoneNumber);
|
|
1827
1902
|
const waVersion = await fetchWaVersion(_device);
|
|
1828
1903
|
const { cc } = parsePhone(phoneNumber);
|
|
1829
|
-
const meta = getCountryMeta(cc);
|
|
1904
|
+
const meta = getCountryMeta(cc, store);
|
|
1830
1905
|
|
|
1831
1906
|
function hasBlock(r) {
|
|
1832
1907
|
return r && !!r.custom_block_screen;
|
|
@@ -1988,7 +2063,7 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1988
2063
|
|
|
1989
2064
|
// Derive real MCC/MNC from the phone number being registered.
|
|
1990
2065
|
const { cc: _regCc } = parsePhone(store.phoneNumber);
|
|
1991
|
-
const _regMeta = getCountryMeta(_regCc);
|
|
2066
|
+
const _regMeta = getCountryMeta(_regCc, store);
|
|
1992
2067
|
|
|
1993
2068
|
async function _tryMethod(m) {
|
|
1994
2069
|
// Retry loop:
|
package/lib/Store.js
CHANGED
|
@@ -113,6 +113,12 @@ function createNewStore(phoneNumber, opts) {
|
|
|
113
113
|
registered: false,
|
|
114
114
|
codePending: false, // true after /code request, cleared on successful /register
|
|
115
115
|
name: normalizePushName(opts.name) || 'User',
|
|
116
|
+
// Which SIM is actually in the phone. The country table can only guess one
|
|
117
|
+
// operator per calling code — number portability broke the prefix→operator
|
|
118
|
+
// link long ago — so whoever is registering can name theirs instead. Null
|
|
119
|
+
// leaves the table's guess in place. See getCountryMeta in Registration.js.
|
|
120
|
+
simMcc: opts.simMcc ? String(opts.simMcc).trim() : null,
|
|
121
|
+
simMnc: opts.simMnc ? String(opts.simMnc).trim() : null,
|
|
116
122
|
version,
|
|
117
123
|
device,
|
|
118
124
|
// ADVSignedDeviceIdentity bytes received from server <success> node.
|
|
@@ -194,6 +200,8 @@ function storeToJson(store) {
|
|
|
194
200
|
advertisingId: store.advertisingId || null,
|
|
195
201
|
backupToken: store.backupToken ? store.backupToken.toString('base64') : null,
|
|
196
202
|
registered: !!store.registered,
|
|
203
|
+
simMcc: store.simMcc || null,
|
|
204
|
+
simMnc: store.simMnc || null,
|
|
197
205
|
codePending: store.codePending || false,
|
|
198
206
|
// Which delivery method the pending code actually went out by. The two
|
|
199
207
|
// registration steps are normally separate commands, so the confirmation
|
|
@@ -246,6 +254,10 @@ function storeFromJson(obj) {
|
|
|
246
254
|
advertisingId: obj.advertisingId || uuidv4(),
|
|
247
255
|
backupToken: obj.backupToken ? Buffer.from(obj.backupToken, 'base64') : crypto.randomBytes(20),
|
|
248
256
|
registered: !!obj.registered,
|
|
257
|
+
// Null on stores written before the SIM could be declared, which simply
|
|
258
|
+
// means the country table's guess stands.
|
|
259
|
+
simMcc: obj.simMcc || null,
|
|
260
|
+
simMnc: obj.simMnc || null,
|
|
249
261
|
codePending: obj.codePending || false,
|
|
250
262
|
codeMethod: obj.codeMethod || null,
|
|
251
263
|
// Absent on stores written before push tokens existed; null simply means
|
package/lib/constants.js
CHANGED
|
@@ -169,91 +169,91 @@ const ANDROID_DEVICE_PROFILES = {
|
|
|
169
169
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
170
170
|
model: 'Samsung Galaxy S24 Ultra', manufacturer: 'Samsung',
|
|
171
171
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
172
|
-
modelId: 'SM-S928B', deviceModelType: 2
|
|
172
|
+
modelId: 'SM-S928B', deviceModelType: 2, ram: '11.55'
|
|
173
173
|
},
|
|
174
174
|
'samsung-s24': {
|
|
175
175
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
176
176
|
model: 'Samsung Galaxy S24', manufacturer: 'Samsung',
|
|
177
177
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
178
|
-
modelId: 'SM-S921B', deviceModelType: 2
|
|
178
|
+
modelId: 'SM-S921B', deviceModelType: 2, ram: '7.63'
|
|
179
179
|
},
|
|
180
180
|
'samsung-s23': {
|
|
181
181
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
182
182
|
model: 'Samsung Galaxy S23', manufacturer: 'Samsung',
|
|
183
183
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
184
|
-
modelId: 'SM-S911B', deviceModelType: 2
|
|
184
|
+
modelId: 'SM-S911B', deviceModelType: 2, ram: '7.63'
|
|
185
185
|
},
|
|
186
186
|
'samsung-s23-ultra': {
|
|
187
187
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
188
188
|
model: 'Samsung Galaxy S23 Ultra', manufacturer: 'Samsung',
|
|
189
189
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
190
|
-
modelId: 'SM-S918B', deviceModelType: 2
|
|
190
|
+
modelId: 'SM-S918B', deviceModelType: 2, ram: '11.55'
|
|
191
191
|
},
|
|
192
192
|
'samsung-a55': {
|
|
193
193
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
194
194
|
model: 'Samsung Galaxy A55', manufacturer: 'Samsung',
|
|
195
195
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
196
|
-
modelId: 'SM-A556B', deviceModelType: 1
|
|
196
|
+
modelId: 'SM-A556B', deviceModelType: 1, ram: '7.44'
|
|
197
197
|
},
|
|
198
198
|
'pixel8pro': {
|
|
199
199
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
200
200
|
model: 'Pixel 8 Pro', manufacturer: 'Google',
|
|
201
201
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
202
|
-
modelId: 'Pixel 8 Pro', deviceModelType: 2
|
|
202
|
+
modelId: 'Pixel 8 Pro', deviceModelType: 2, ram: '11.44'
|
|
203
203
|
},
|
|
204
204
|
'pixel8': {
|
|
205
205
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
206
206
|
model: 'Pixel 8', manufacturer: 'Google',
|
|
207
207
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
208
|
-
modelId: 'Pixel 8', deviceModelType: 2
|
|
208
|
+
modelId: 'Pixel 8', deviceModelType: 2, ram: '7.53'
|
|
209
209
|
},
|
|
210
210
|
'pixel7': {
|
|
211
211
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
212
212
|
model: 'Pixel 7', manufacturer: 'Google',
|
|
213
213
|
osVersion: '14', osBuildNumber: 'TP1A.221005.002',
|
|
214
|
-
modelId: 'Pixel 7', deviceModelType: 2
|
|
214
|
+
modelId: 'Pixel 7', deviceModelType: 2, ram: '7.53'
|
|
215
215
|
},
|
|
216
216
|
'pixel7a': {
|
|
217
217
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
218
218
|
model: 'Pixel 7a', manufacturer: 'Google',
|
|
219
219
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
220
|
-
modelId: 'Pixel 7a', deviceModelType: 1
|
|
220
|
+
modelId: 'Pixel 7a', deviceModelType: 1, ram: '7.44'
|
|
221
221
|
},
|
|
222
222
|
'xiaomi14': {
|
|
223
223
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
224
224
|
model: 'Xiaomi 14', manufacturer: 'Xiaomi',
|
|
225
225
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
226
|
-
modelId: '23127PN0CG', deviceModelType: 2
|
|
226
|
+
modelId: '23127PN0CG', deviceModelType: 2, ram: '11.36'
|
|
227
227
|
},
|
|
228
228
|
'xiaomi13': {
|
|
229
229
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
230
230
|
model: 'Xiaomi 13', manufacturer: 'Xiaomi',
|
|
231
231
|
osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
|
|
232
|
-
modelId: '2211133G', deviceModelType: 2
|
|
232
|
+
modelId: '2211133G', deviceModelType: 2, ram: '7.51'
|
|
233
233
|
},
|
|
234
234
|
'oneplus12': {
|
|
235
235
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
236
236
|
model: 'OnePlus 12', manufacturer: 'OnePlus',
|
|
237
237
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
238
|
-
modelId: 'CPH2573', deviceModelType: 2
|
|
238
|
+
modelId: 'CPH2573', deviceModelType: 2, ram: '11.48'
|
|
239
239
|
},
|
|
240
240
|
'oneplus11': {
|
|
241
241
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
242
242
|
model: 'OnePlus 11', manufacturer: 'OnePlus',
|
|
243
243
|
osVersion: '13', osBuildNumber: 'TQ3A.230901.001',
|
|
244
|
-
modelId: 'CPH2449', deviceModelType: 2
|
|
244
|
+
modelId: 'CPH2449', deviceModelType: 2, ram: '15.32'
|
|
245
245
|
},
|
|
246
246
|
'oppo-find-x7': {
|
|
247
247
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
248
248
|
model: 'OPPO Find X7', manufacturer: 'OPPO',
|
|
249
249
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
250
|
-
modelId: 'CPH2599', deviceModelType: 2
|
|
250
|
+
modelId: 'CPH2599', deviceModelType: 2, ram: '11.40'
|
|
251
251
|
},
|
|
252
252
|
'realme-gt5': {
|
|
253
253
|
os: 'android', platform: PLATFORM.ANDROID,
|
|
254
254
|
model: 'realme GT 5 Pro', manufacturer: 'realme',
|
|
255
255
|
osVersion: '14', osBuildNumber: 'UP1A.231005.007',
|
|
256
|
-
modelId: 'RMX3888', deviceModelType: 2
|
|
256
|
+
modelId: 'RMX3888', deviceModelType: 2, ram: '15.28'
|
|
257
257
|
}
|
|
258
258
|
};
|
|
259
259
|
|
|
@@ -794,11 +794,47 @@ function decodeMessageContainer(buf, depth) {
|
|
|
794
794
|
}
|
|
795
795
|
|
|
796
796
|
// Field 6: extendedTextMessage — field 1 = text
|
|
797
|
+
//
|
|
798
|
+
// A text message with a link in it comes as one of these, and everything
|
|
799
|
+
// the sender's client built for the preview rides along: the title and
|
|
800
|
+
// description it scraped, and a thumbnail. The thumbnail is a media file of
|
|
801
|
+
// its own — its own directPath, its own digests, its own mediaKey — not a
|
|
802
|
+
// part of the message, which is why only the inline jpegThumbnail was ever
|
|
803
|
+
// reachable and the full-size one could not be fetched at all.
|
|
804
|
+
//
|
|
805
|
+
// text=1 matchedText=2 description=5 title=6 jpegThumbnail=16
|
|
806
|
+
// thumbnailDirectPath=19 thumbnailSha256=20 thumbnailEncSha256=21
|
|
807
|
+
// mediaKey=22
|
|
797
808
|
if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
|
|
798
809
|
try {
|
|
799
|
-
const ext
|
|
810
|
+
const ext = _decodeFields(f[6]);
|
|
800
811
|
const text = _str(ext[1]);
|
|
801
|
-
if (text)
|
|
812
|
+
if (text) {
|
|
813
|
+
msgResult = { type: 'text', text };
|
|
814
|
+
|
|
815
|
+
const matchedText = _str(ext[2]);
|
|
816
|
+
const description = _str(ext[5]);
|
|
817
|
+
const title = _str(ext[6]);
|
|
818
|
+
if (matchedText) msgResult.matchedText = matchedText;
|
|
819
|
+
if (description) msgResult.description = description;
|
|
820
|
+
if (title) msgResult.title = title;
|
|
821
|
+
if (Buffer.isBuffer(ext[16]) && ext[16].length) {
|
|
822
|
+
msgResult.jpegThumbnail = ext[16];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// The downloadable preview image, when the sender attached one. All
|
|
826
|
+
// four are needed together: without the key it cannot be decrypted,
|
|
827
|
+
// and without the digests it cannot be checked.
|
|
828
|
+
const thumbPath = _str(ext[19]);
|
|
829
|
+
if (thumbPath && Buffer.isBuffer(ext[22]) && ext[22].length) {
|
|
830
|
+
msgResult.thumbnail = {
|
|
831
|
+
directPath: thumbPath,
|
|
832
|
+
mediaKey: ext[22],
|
|
833
|
+
thumbnailSha256: Buffer.isBuffer(ext[20]) ? ext[20] : null,
|
|
834
|
+
thumbnailEncSha256: Buffer.isBuffer(ext[21]) ? ext[21] : null
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
}
|
|
802
838
|
} catch (_) {}
|
|
803
839
|
}
|
|
804
840
|
|
|
@@ -1044,9 +1080,42 @@ function decodeMessageContainer(buf, depth) {
|
|
|
1044
1080
|
} catch (_) { msgResult = { type: 'poll', question: '', options: [] }; }
|
|
1045
1081
|
}
|
|
1046
1082
|
|
|
1047
|
-
// Field 50: pollUpdateMessage
|
|
1083
|
+
// Field 50: pollUpdateMessage — somebody voted on a poll.
|
|
1084
|
+
//
|
|
1085
|
+
// PollUpdateMessage { pollCreationMessageKey=1, vote=2,
|
|
1086
|
+
// senderTimestampMs=3 }
|
|
1087
|
+
// MessageKey { remoteJid=1, fromMe=2, id=3 }
|
|
1088
|
+
// PollEncValue { encPayload=1, encIV=2 }
|
|
1089
|
+
//
|
|
1090
|
+
// The vote itself is encrypted under a key derived from the poll's message
|
|
1091
|
+
// secret, so the ballot cannot be read from the stanza alone — see
|
|
1092
|
+
// decryptPollVote. Every one of these decoded to the bare word 'pollVote'
|
|
1093
|
+
// and nothing else, which left no way to tell which poll had been voted on,
|
|
1094
|
+
// let alone what the vote was.
|
|
1048
1095
|
if (!msgResult && f[50] && Buffer.isBuffer(f[50])) {
|
|
1049
1096
|
msgResult = { type: 'pollVote' };
|
|
1097
|
+
try {
|
|
1098
|
+
const upd = _decodeFields(f[50]);
|
|
1099
|
+
|
|
1100
|
+
if (Buffer.isBuffer(upd[1])) {
|
|
1101
|
+
const key = _decodeFields(upd[1]);
|
|
1102
|
+
msgResult.pollKey = {
|
|
1103
|
+
remoteJid: _str(key[1]) || null,
|
|
1104
|
+
fromMe: !!key[2],
|
|
1105
|
+
id: _str(key[3]) || null
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (Buffer.isBuffer(upd[2])) {
|
|
1110
|
+
const vote = _decodeFields(upd[2]);
|
|
1111
|
+
if (Buffer.isBuffer(vote[1]) && Buffer.isBuffer(vote[2])) {
|
|
1112
|
+
msgResult.encPayload = vote[1];
|
|
1113
|
+
msgResult.encIv = vote[2];
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (typeof upd[3] === 'number') msgResult.senderTimestampMs = upd[3];
|
|
1118
|
+
} catch (_) { /* the bare type is still worth reporting */ }
|
|
1050
1119
|
}
|
|
1051
1120
|
|
|
1052
1121
|
// If we found a real message, attach skdmInfo / messageSecret and return.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.29.
|
|
3
|
+
"version": "5.29.4",
|
|
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",
|