whalibmob 5.17.0 → 5.19.0

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
@@ -1788,9 +1788,9 @@ This runs by itself on a registration that finds no material. To do it separatel
1788
1788
  wa apk-material --download
1789
1789
  ```
1790
1790
 
1791
- It is the route Cobalt takes: an anonymous Play Store token from the Aurora OSS
1792
- dispenser, then Google's own `/fdfe` catalogue and delivery endpoints, then the
1793
- signed CDN URLs. **No Google account of yours is involved.** Only the density
1791
+ The route is an anonymous Play Store token from the Aurora OSS dispenser, then
1792
+ Google's own `/fdfe` catalogue and delivery endpoints, then the signed CDN URLs.
1793
+ **No Google account of yours is involved.** Only the density
1794
1794
  splits are downloaded — the token's key comes from `about_logo.png`, which lives
1795
1795
  in one of those, and the architecture and language splits would be a hundred
1796
1796
  megabytes nothing here reads.
@@ -2322,6 +2322,8 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
2322
2322
  | `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
2323
2323
  | `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
2324
2324
  | `client_rejected` | `{ reason, location, message }` | the server refused the client itself, not the session — `405` means the announced version is not accepted. Fires whether the refusal arrives during the handshake or once the stream is open, and the client stops retrying either way. Distinct from `auth_failure`, and there is nothing to re-pair. |
2325
+ | `version_update` | `{ from, to, source }` | the announced version was refreshed from the platform's store before a handshake. Fires on reconnects as well as the first connect |
2326
+ | `apk_material_stale` | `{ materialVersion, liveVersion, hint }` | Android only: the Play Store listing has moved past the APK the cached token material came from. Nothing is broken — registration is what reads that material — but the next number registered from this install would go out under an older build |
2325
2327
 
2326
2328
  ### Reading What the Phone Sent
2327
2329
 
@@ -2585,40 +2587,53 @@ A session records the version it registered with. Left alone it announces that
2585
2587
  same number forever, so a number registered in spring is still claiming a spring
2586
2588
  build in autumn, and one day the connect simply stops working.
2587
2589
 
2588
- **iOS sessions now refresh themselves.** On every `init()` the client asks the
2589
- App Store what the current build is and writes it into `<phone>.json` before the
2590
- handshake. Nothing to run, nothing to remember:
2590
+ **Sessions refresh themselves, on both platforms.** Before every handshake the
2591
+ client asks the platform's store what the current build is and writes it into
2592
+ `<phone>.json`. iOS reads the App Store listing, Android the Play Store one.
2593
+ Nothing to run, nothing to remember:
2591
2594
 
2592
2595
  ```js
2593
- client.on('version_update', ({ from, to }) => {
2594
- console.log('announcing', to, 'instead of', from)
2596
+ client.on('version_update', ({ from, to, source }) => {
2597
+ console.log('announcing', to, 'instead of', from, '—', source)
2595
2598
  })
2596
2599
 
2597
2600
  await client.init('40756469325')
2598
2601
  ```
2599
2602
 
2600
- Three rules keep this from being the thing that breaks a working session:
2603
+ **Reconnects are covered too.** The check runs from the socket-connect step
2604
+ rather than from `init()`, so the library's own reconnect loop passes through it
2605
+ as well. A bot that stays up for weeks does not go on announcing whatever the
2606
+ listing said the morning it started. The handshake reads the version out of the
2607
+ store at the moment it runs, so a version written here is the one that goes out.
2601
2608
 
2602
- - **It never goes backwards.** The App Store lookup answers with a pinned
2603
- fallback rather than failing when it cannot reach the network, and that
2604
- fallback can easily be older than what the session holds. Moving a session to
2605
- an older version is the one outcome that makes a `405` *more* likely.
2609
+ Four rules keep this from being the thing that breaks a working session:
2610
+
2611
+ - **It never goes backwards.** Both lookups answer with a pinned fallback rather
2612
+ than failing when they cannot reach the network, and that fallback can easily
2613
+ be older than what the session holds. Moving a session to an older version is
2614
+ the one outcome that makes a `405` *more* likely.
2606
2615
  - **`WA_VERSION` still wins.** A version pinned on the way out of a `405` is a
2607
2616
  decision, and is never quietly replaced.
2608
2617
  - **A failed lookup changes nothing.** The session keeps the version it has and
2609
2618
  the connect carries on.
2619
+ - **The network is not asked twice for the same answer.** Each lookup is
2620
+ memoised for six hours, so a connection that flaps every few seconds reaches
2621
+ the store once rather than once per attempt — and a session up for a month
2622
+ still picks up a release the day it lands.
2610
2623
 
2611
2624
  Set `{ refreshVersion: false }` on the client to turn it off.
2612
2625
 
2613
2626
  > [!NOTE]
2614
- > **Android is deliberately left alone.** Its version comes out of the APK the
2615
- > token material was read from, and the two have to agree — the registration
2616
- > token is computed from that build. Refreshing the announced version behind the
2617
- > caller's back would put it out of step with the token. Android keeps the
2618
- > explicit flow: `wa apk-material --download`, then `wa refresh-version`.
2627
+ > **Registration still reads the APK.** A number being registered announces the
2628
+ > `versionName` of the APK its token material came from, because the Android
2629
+ > registration token is signed over that build. Only the *announced* version on
2630
+ > an already-registered session follows the Play Store listing — no token
2631
+ > travels in the handshake, so there is nothing there for it to fall out of step
2632
+ > with. When the listing moves past the cached material the client emits
2633
+ > `apk_material_stale`, which is the cue to run `wa apk-material --download`
2634
+ > before registering the next number.
2619
2635
 
2620
- The manual tool still works on both platforms, and is the only way to move an
2621
- Android session:
2636
+ The manual tool still works on both platforms:
2622
2637
 
2623
2638
  ```bash
2624
2639
  wa refresh-version 40756469325 # current build for the platform
@@ -4867,7 +4882,7 @@ come from:
4867
4882
  | order | where the announced version comes from |
4868
4883
  |---|---|
4869
4884
  | 1 | `WA_VERSION`, from the shell **or from a `.env` file in the directory the command runs in** |
4870
- | 2 | the version stored in the session file — what the number was registered with |
4885
+ | 2 | the version stored in the session file — refreshed from the platform's store before every handshake, unless `{ refreshVersion: false }` |
4871
4886
 
4872
4887
  Almost every 405 is the first line winning when nobody meant it to.
4873
4888
 
package/lib/AndroidApk.js CHANGED
@@ -9,8 +9,7 @@
9
9
  // put in WA_STATIC_TOKEN can stand in for it. Sending the iOS-shaped token as
10
10
  // Android is answered with {"reason":"bad_token"}.
11
11
  //
12
- // What the native client actually computes, and what this file reproduces from
13
- // Cobalt's WhatsAppAndroidClientInfo:
12
+ // What the native client actually computes, and what this file reproduces:
14
13
  //
15
14
  // key = PBKDF2-HMAC-SHA1(password = packageName || about_logo.png,
16
15
  // salt = ANDROID_SALT, iterations = 128, dkLen = 64)
@@ -27,7 +26,7 @@ const crypto = require('crypto');
27
26
  const zlib = require('zlib');
28
27
 
29
28
  // Reverse engineered from the Android binary and identical for the consumer and
30
- // business builds. Cobalt notes it has been stable across many releases.
29
+ // business builds, and has been stable across many releases.
31
30
  const ANDROID_SALT = Buffer.from(
32
31
  'PkTwKSZqUfAUyR0rPQ8hYJ0wNsQQ3dW1+3SCnyTXIfEAxxS75FwkDf47wNv/c8pP' +
33
32
  '3p0GXKR6OOQmhyERwx74fw1RYSU10I4r1gyBVDbRJ40pidjM41G1I1oN', 'base64');
@@ -124,7 +123,7 @@ function readZipEntry(buf, entries, name) {
124
123
  // ─── DER / PKCS#7 ─────────────────────────────────────────────────────────────
125
124
  //
126
125
  // 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
126
+ // what the token derivation needs — the v1 signer list. Only
128
127
  // enough DER is parsed to walk into SignedData and lift each certificate out
129
128
  // whole; nothing here validates a signature.
130
129
 
@@ -174,7 +173,7 @@ function certificatesFromPkcs7(der) {
174
173
  //
175
174
  // The manifest inside an APK is Android's binary XML, not text. Three things are
176
175
  // 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
176
+ // all three off the parsed manifest. The version name is the
178
177
  // one that matters most: the token is signed over this APK's classes.dex, so the
179
178
  // version announced to the server has to be this APK's, not whatever the Play
180
179
  // Store currently lists.
@@ -268,8 +267,8 @@ function parseManifest(buf) {
268
267
  }
269
268
 
270
269
  // 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.
270
+ // "base-config.xxhdpi.apk" or "split_config.xxhdpi.apk". Only splits whose name
271
+ // ends in "dpi" are searched, which is the density-qualified set.
273
272
  function _isDensitySplit(fileName) {
274
273
  const base = String(fileName).replace(/\.apk$/i, '');
275
274
  return /dpi$/i.test(base);
@@ -295,8 +294,8 @@ function extractMaterial(base, splits) {
295
294
  // — and its bytes are the PBKDF2 password, so picking a different density
296
295
  // yields a different key and a token the server will not accept. Searching
297
296
  // 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.
297
+ // passed in, which for a shell glob is alphabetical and for a download map is
298
+ // whatever it iterated: same APK, different token.
300
299
  //
301
300
  // So the path decides, not the split. ABOUT_LOGO_PATHS is ordered — hdpi
302
301
  // first, the xxhdpi fallback last — and every candidate is searched for the
@@ -355,9 +354,8 @@ function extractMaterial(base, splits) {
355
354
 
356
355
  // PBKDF2-HMAC-SHA1 over the package name followed by the raw PNG bytes.
357
356
  //
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.
357
+ // A JCA factory rejects a binary password, so a hand-rolled loop is the usual
358
+ // route on the JVM; that loop is standard PBKDF2, so this calls the platform's.
361
359
  function deriveSecretKey(packageName, aboutLogo) {
362
360
  const password = Buffer.concat([Buffer.from(packageName, 'utf8'), aboutLogo]);
363
361
  return crypto.pbkdf2Sync(password, ANDROID_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_SIZE, 'sha1');
@@ -415,8 +413,7 @@ function looksLikeWhatsAppCertificate(description) {
415
413
 
416
414
  // ─── Storage ──────────────────────────────────────────────────────────────────
417
415
  //
418
- // Only the derived pieces are kept, the way Cobalt caches them — the APK itself
419
- // is never needed again.
416
+ // Only the derived pieces are kept — the APK itself is never needed again.
420
417
 
421
418
  function materialToJson(material) {
422
419
  return {
@@ -4,10 +4,9 @@
4
4
  //
5
5
  // Talks to the on-device Frida attestation server shipped in ./frida (Android
6
6
  // Play Integrity / Keystore attestation, iOS App Attest) and folds its output
7
- // into the registration request, mirroring how Auties00/cobalt consumes the
8
- // same scripts.
7
+ // into the registration request.
9
8
  //
10
- // Design (matches Cobalt's LinkedWhatsAppClientDeviceAttestor):
9
+ // Design:
11
10
  // • Two data shapes are produced —
12
11
  // Android: { gpia, gg, gi, gp, ge, ga } (Play Integrity legs)
13
12
  // iOS: { attestation, assertion, keyId } (App Attest)
@@ -155,7 +154,7 @@ async function iosAppAttest(noisePubB64, device) {
155
154
 
156
155
  // ─── High-level: attestation fields shipped on every attested endpoint ────────
157
156
  //
158
- // Returns the alternating name/value pairs Cobalt ships via attestationFields():
157
+ // Returns the alternating name/value pairs the attested endpoints expect:
159
158
  // Android → gpia,_gg,_gi,_gp,_ge,_ga,push_token
160
159
  // iOS → push_token
161
160
  //
package/lib/Client.js CHANGED
@@ -9,7 +9,7 @@ const crypto = require('crypto');
9
9
  const { Mutex } = require('async-mutex');
10
10
  const { NoiseSocket } = require('./noise');
11
11
  const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
12
- const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
12
+ const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchAndroidVersion, fetchWaVersion } = require('./Registration');
13
13
  const { defaultBaseDir, sessionDirFor, isLegacyLayout,
14
14
  preKeyFilesFor, SESSION_SUFFIXES } = require('./SessionPaths');
15
15
  const { getDeviceConfig } = require('./DeviceConfig');
@@ -379,12 +379,11 @@ class WhalibmobClient extends EventEmitter {
379
379
  // automatically on the first refused login. Set { autoFixNumber: false } to
380
380
  // be told about it instead.
381
381
  this._autoFixNumber = opts.autoFixNumber !== false;
382
- // An iOS session checks the App Store for the current build on every
383
- // connect and writes it back — see _refreshIosVersion(). Set
384
- // { refreshVersion: false } to keep announcing whatever the session
385
- // registered with. Android is not affected either way: its version comes
386
- // out of the APK the token material was read from, and that stays a
387
- // deliberate step.
382
+ // A session checks its platform's store for the current build before every
383
+ // handshake and writes it back — see _refreshAnnouncedVersion(). iOS reads
384
+ // the App Store listing, Android the Play Store one, and reconnects are
385
+ // covered as well as the first connect. Set { refreshVersion: false } to
386
+ // keep announcing whatever the session registered with.
388
387
  this._refreshVersion = opts.refreshVersion !== false;
389
388
  this._numberFixAttempted = false;
390
389
  this._pingTimer = null;
@@ -505,7 +504,7 @@ class WhalibmobClient extends EventEmitter {
505
504
  }
506
505
 
507
506
  /**
508
- * Bring an iOS session's announced version up to date, on every connect.
507
+ * Bring a session's announced version up to date, before every handshake.
509
508
  *
510
509
  * A session records the version it registered with and announces that
511
510
  * forever after. Months later the server stops accepting it and the connect
@@ -515,56 +514,111 @@ class WhalibmobClient extends EventEmitter {
515
514
  * connectWeb() reads the live revision every time; this is the same idea for
516
515
  * the primary one.
517
516
  *
518
- * iOS only, and deliberately so. Android's version comes out of the APK the
519
- * token material was read from — the two have to agree, because the token is
520
- * computed from that build — so refreshing it behind the caller's back would
521
- * put the announced version and the token out of step. Android keeps its
522
- * Play Store flow exactly as it was.
517
+ * Both platforms, and the version each one asks for is the one that platform
518
+ * publishes: the App Store listing for iOS, the Play Store listing for
519
+ * Android.
520
+ *
521
+ * Android was left out of this at first, on the grounds that its version has
522
+ * to match the APK the token material came from. That holds for registering a
523
+ * number and nowhere else. The Android token is an HMAC over the APK's
524
+ * signing certificates, the digest of its classes.dex and the national
525
+ * number — the version string is not in it — and no token of any kind travels
526
+ * in the handshake, which carries the version, the username and the device
527
+ * properties. So there is nothing here for a refreshed version to fall out of
528
+ * step with. Registration keeps reading the APK's own version through
529
+ * fetchWaVersion(), so a number being registered still announces the build
530
+ * its token was computed from.
531
+ *
532
+ * Called from _connectSocket() rather than init(), so it covers the
533
+ * library's own reconnects too. The handshake reads store.version at the
534
+ * moment it runs, so a version written here is the one that goes out.
523
535
  *
524
536
  * Three rules keep this from being the thing that breaks a working session:
525
537
  *
526
- * • It never goes backwards. fetchIosVersion() answers with the pinned
527
- * fallback rather than failing when the lookup does not work, and that
528
- * fallback can easily be older than what a session already holds. Moving
529
- * a session to an older version is the one outcome that makes a 405 more
530
- * likely rather than less.
538
+ * • It never goes backwards. Both lookups answer with a pinned fallback
539
+ * rather than failing, and that fallback can easily be older than what a
540
+ * session already holds. Moving a session to an older version is the one
541
+ * outcome that makes a 405 more likely rather than less.
531
542
  * • WA_VERSION still wins. Someone who has pinned a version on the way out
532
543
  * of a 405 must not have it quietly replaced.
533
544
  * • It never throws, and it never blocks for long. A lookup that fails
534
- * leaves the session exactly as it was and the connect carries on.
545
+ * leaves the session exactly as it was and the connect carries on. The
546
+ * lookups memoise for six hours, so a connection that flaps every few
547
+ * seconds reaches the network once, not once per attempt.
535
548
  */
536
- async _refreshIosVersion() {
549
+ async _refreshAnnouncedVersion() {
537
550
  if (!this._refreshVersion || this._mode === 'web' || !this._store) return;
538
551
  if (process.env.WA_VERSION) {
539
552
  _whaDbg('[DBG] VERSION refresh skipped — WA_VERSION is set');
540
553
  return;
541
554
  }
542
555
 
543
- const device = this._store.device || getDeviceConfig();
544
- if (device.os === 'android') return; // the APK flow owns this
556
+ const device = this._store.device || getDeviceConfig();
557
+ const business = !!device.business;
558
+ const android = device.os === 'android';
559
+ const source = android ? 'Play Store listing' : 'App Store listing';
545
560
 
546
561
  try {
547
562
  const { compareVersions } = require('./Registration');
548
563
  const before = this._store.version;
549
- const live = await fetchIosVersion(!!device.business);
564
+ const live = android
565
+ ? await fetchAndroidVersion(business)
566
+ : await fetchIosVersion(business);
550
567
  if (!live) return;
551
568
 
552
569
  if (before && compareVersions(live, before) <= 0) {
553
- _whaDbg('[DBG] VERSION already current (' + before + ', App Store says ' + live + ')');
570
+ _whaDbg('[DBG] VERSION already current (' + before + ', ' + source +
571
+ ' says ' + live + ')');
554
572
  return;
555
573
  }
556
574
 
557
575
  this._store.version = live;
558
576
  this._persistStore();
559
577
  _whaDbg('[DBG] VERSION refreshed ' + (before || 'none') + ' → ' + live +
560
- ' (App Store listing)');
561
- this.emit('version_update', { from: before || null, to: live, source: 'App Store listing' });
578
+ ' (' + source + ')');
579
+ this.emit('version_update', { from: before || null, to: live, source });
580
+
581
+ // On Android the token material carries the version of the APK it was
582
+ // read from. Once the listing has moved past it, that material is from an
583
+ // older build — which matters for registering a number, not for this
584
+ // connection. Say so rather than fetching an APK on the connect path.
585
+ if (android) this._warnIfApkMaterialStale(device, live);
562
586
  } catch (err) {
563
587
  // A session that cannot ask keeps the version it has.
564
588
  _whaDbg('[DBG] VERSION refresh failed: ' + (err && err.message));
565
589
  }
566
590
  }
567
591
 
592
+ /**
593
+ * Note that the cached APK material predates the build Play is serving.
594
+ *
595
+ * Only registration reads that material, so nothing is broken here and
596
+ * nothing is downloaded. It is said once per connection so that a number
597
+ * registered from this install goes out under a current build if the owner
598
+ * refreshes the material first.
599
+ */
600
+ _warnIfApkMaterialStale(device, liveVersion) {
601
+ try {
602
+ const { compareVersions, tryLoadAndroidMaterial } = require('./Registration');
603
+ if (typeof tryLoadAndroidMaterial !== 'function') return;
604
+
605
+ const material = tryLoadAndroidMaterial(device);
606
+ const apkVersion = material && material.apkVersion;
607
+ if (!apkVersion) return;
608
+ if (compareVersions(liveVersion, apkVersion) <= 0) return;
609
+
610
+ _whaDbg('[DBG] APK material is from ' + apkVersion + ', the listing serves ' +
611
+ liveVersion + ' — run `wa apk-material --download` before registering a number');
612
+ this.emit('apk_material_stale', {
613
+ materialVersion: apkVersion,
614
+ liveVersion,
615
+ hint: 'wa apk-material --download'
616
+ });
617
+ } catch (_) {
618
+ // Advisory only.
619
+ }
620
+ }
621
+
568
622
  async init(phoneNumber) {
569
623
  phoneNumber = String(phoneNumber).replace(/\D/g, '');
570
624
  this._phoneNumber = phoneNumber;
@@ -594,10 +648,6 @@ class WhalibmobClient extends EventEmitter {
594
648
  );
595
649
  }
596
650
 
597
- // Before the handshake, which reads the version straight out of the store.
598
- // iOS only; Android keeps its APK flow. See _refreshIosVersion().
599
- await this._refreshIosVersion();
600
-
601
651
  const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
602
652
  const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
603
653
  const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.tctoken.json`);
@@ -702,6 +752,15 @@ class WhalibmobClient extends EventEmitter {
702
752
  async _connectSocket() {
703
753
  if (this._mode === 'web') return this._connectWebSocket();
704
754
 
755
+ // Before the handshake, which reads the version straight out of the store.
756
+ //
757
+ // Here rather than in init() so that the library's own reconnects get it
758
+ // too: _scheduleReconnect() calls this method directly, and a session that
759
+ // refreshed only at startup would announce whatever the listing said the
760
+ // day the process began, however long it then stayed up. The lookups
761
+ // memoise for six hours, so a flapping connection costs nothing.
762
+ await this._refreshAnnouncedVersion();
763
+
705
764
  const socket = new NoiseSocket(this._store);
706
765
  this._socket = socket;
707
766
 
@@ -3433,6 +3492,54 @@ class WhalibmobClient extends EventEmitter {
3433
3492
  const isLid = fromJid.endsWith('@lid');
3434
3493
  const mappedPn = isLid && this._lidToPn ? this._lidToPn.get(fromUser) : null;
3435
3494
  const fromPhone = isLid ? mappedPn : fromUser;
3495
+
3496
+ // A contact has two device lists — one under the phone number, one under
3497
+ // the LID — and the notification carries both: `device_hash` for the list
3498
+ // it is addressed under, `device_lid_hash` for the other one, with each
3499
+ // <device> naming its jid and its lid. The server hashes them separately,
3500
+ // so they are checked separately, and one going wrong does not cost the
3501
+ // other. That matters most for the LID half: a LID cache thrown away for
3502
+ // a contact whose number we do not know cannot be refilled by usync,
3503
+ // which only answers questions about phone numbers.
3504
+ const lidAttr = String(attrs.lid || '');
3505
+ const lidUser = isLid ? fromUser
3506
+ : (lidAttr ? lidAttr.split('@')[0].split(':')[0] : null);
3507
+ // Free mapping, exactly as the notification states it.
3508
+ if (!isLid && lidUser && fromUser) {
3509
+ if (this._lidToPn) this._lidToPn.set(lidUser, fromUser);
3510
+ if (this._pnToLid) this._pnToLid.set(fromUser, lidUser);
3511
+ }
3512
+
3513
+ // Before throwing the cache away: the notification says what changed and
3514
+ // carries the hash of the list it should produce. Apply it, check the
3515
+ // hash, and if it matches there is nothing to invalidate and nothing to
3516
+ // ask usync about — see DeviceManager.verifyDeviceDelta().
3517
+ if (fromUser && this._devMgr) {
3518
+ const delta = this._parseDeviceDelta(node);
3519
+ if (delta) {
3520
+ // The primary half is keyed the way the notification is addressed.
3521
+ const priKey = isLid ? 'lid:' + fromUser : fromUser;
3522
+ const priServer = isLid ? 'lid' : 's.whatsapp.net';
3523
+ const priOk = this._devMgr.verifyDeviceDelta(
3524
+ priKey, fromUser, delta.changes, delta.hash, priServer);
3525
+
3526
+ // The secondary half only exists when the notification arrived under
3527
+ // the number and named the LID alongside it.
3528
+ if (!isLid && lidUser && delta.lidHash && delta.lidChanges) {
3529
+ const lidOk = this._devMgr.verifyDeviceDelta(
3530
+ 'lid:' + lidUser, lidUser, delta.lidChanges, delta.lidHash, 'lid');
3531
+ _whaDbg('[DBG] NOTIF_DEVICES lid delta ' +
3532
+ (lidOk ? 'verified' : 'unverified') + ' for lid:' + lidUser);
3533
+ }
3534
+
3535
+ if (priOk) {
3536
+ _whaDbg('[DBG] NOTIF_DEVICES delta verified for ' + priKey +
3537
+ ' — cache kept, no usync needed');
3538
+ return;
3539
+ }
3540
+ }
3541
+ }
3542
+
3436
3543
  if (fromUser && this._devMgr) {
3437
3544
  this._devMgr._dcDel(isLid ? [fromUser, 'lid:' + fromUser] : [fromUser]);
3438
3545
  if (mappedPn) this._devMgr._dcDel([mappedPn]);
@@ -3484,6 +3591,86 @@ class WhalibmobClient extends EventEmitter {
3484
3591
  }
3485
3592
  }
3486
3593
 
3594
+ /**
3595
+ * Read a `devices` notification as a change plus the hash it should produce.
3596
+ *
3597
+ * <notification type="devices" from="…">
3598
+ * <add device_hash="2:…"><device jid="40711111111:3@s.whatsapp.net"/></add>
3599
+ * </notification>
3600
+ *
3601
+ * Children are `add`, `remove`, or `update`. Only the first two say something
3602
+ * that can be applied; `update` does not name what it changed, so it is
3603
+ * treated as unreadable here and the caller falls back to invalidating.
3604
+ *
3605
+ * All children must agree on the hash — the server sends one per child, and a
3606
+ * notification carrying two different ones is describing intermediate states
3607
+ * this cannot reconstruct.
3608
+ *
3609
+ * Each child may also carry `device_lid_hash`, and each `<device>` a `lid`
3610
+ * beside its `jid`, describing the same change to the contact's LID device
3611
+ * list. That half is optional: missing on any child, and only the phone-number
3612
+ * half is returned, which is what the caller then verifies. It is never a
3613
+ * reason to reject the notification outright — the two lists are hashed
3614
+ * independently by the server and are checked independently here.
3615
+ *
3616
+ * @returns {{changes: Array<{tag, deviceId}>, hash: string,
3617
+ * lidChanges: Array<{tag, deviceId}>|null, lidHash: string|null}|null}
3618
+ */
3619
+ _parseDeviceDelta(node) {
3620
+ if (!node || !Array.isArray(node.content)) return null;
3621
+
3622
+ const changes = [];
3623
+ const lidChanges = [];
3624
+ let hash = null;
3625
+ let lidHash = null;
3626
+ let lidOk = true; // every child so far carried a usable LID half
3627
+
3628
+ // "user:3@server" / "user@server" → 3 / 0, or null when unreadable.
3629
+ const deviceIdOf = (jid) => {
3630
+ if (!jid) return null;
3631
+ const raw = String(jid).split('@')[0];
3632
+ const colon = raw.indexOf(':');
3633
+ if (colon < 0) return 0;
3634
+ const device = parseInt(raw.slice(colon + 1), 10);
3635
+ return Number.isFinite(device) ? device : null;
3636
+ };
3637
+
3638
+ for (const child of node.content) {
3639
+ if (!child || !child.description) continue;
3640
+ const tag = child.description;
3641
+ if (tag !== 'add' && tag !== 'remove') return null; // 'update' and friends
3642
+
3643
+ const childHash = child.attrs && child.attrs.device_hash;
3644
+ if (!childHash) return null;
3645
+ if (hash === null) hash = String(childHash);
3646
+ else if (hash !== String(childHash)) return null; // disagreeing children
3647
+
3648
+ const deviceNode = findChild(child, 'device');
3649
+ const attrs = (deviceNode && deviceNode.attrs) || {};
3650
+ const device = deviceIdOf(attrs.jid);
3651
+ if (device === null) return null;
3652
+ changes.push({ tag, deviceId: device });
3653
+
3654
+ // LID half — optional, and giving up on it costs the number half nothing.
3655
+ if (!lidOk) continue;
3656
+ const childLidHash = child.attrs.device_lid_hash;
3657
+ const lidDevice = deviceIdOf(attrs.lid);
3658
+ if (!childLidHash || lidDevice === null) { lidOk = false; continue; }
3659
+ if (lidHash === null) lidHash = String(childLidHash);
3660
+ else if (lidHash !== String(childLidHash)) { lidOk = false; continue; }
3661
+ lidChanges.push({ tag, deviceId: lidDevice });
3662
+ }
3663
+
3664
+ if (!changes.length || !hash) return null;
3665
+ const haveLid = lidOk && lidHash && lidChanges.length === changes.length;
3666
+ return {
3667
+ changes,
3668
+ hash,
3669
+ lidChanges: haveLid ? lidChanges : null,
3670
+ lidHash: haveLid ? lidHash : null
3671
+ };
3672
+ }
3673
+
3487
3674
  _processDeviceUpdate(devicesNode) {
3488
3675
  // Called from _handleNotification(type='account_sync') and type='devices'.
3489
3676
  // Rebuilds the device cache for the affected phones from the server-provided list.
@@ -5551,10 +5738,9 @@ class WhalibmobClient extends EventEmitter {
5551
5738
 
5552
5739
  // Change bio/about text
5553
5740
  //
5554
- // The <status> child is byte-for-byte what Baileys and whatsmeow send. The
5555
- // envelope was not: both of them address this IQ to the server as a *JID*
5556
- // with nobody in the user part — Baileys writes '@s.whatsapp.net', whatsmeow
5557
- // uses types.ServerJID — which goes out as JID_PAIR + empty user + the server
5741
+ // The <status> child is the shape the server reads. The envelope was not:
5742
+ // this IQ has to be addressed to the server as a *JID* with nobody in the
5743
+ // user part, which goes out as JID_PAIR + empty user + the server
5558
5744
  // token. This wrote the bare string 's.whatsapp.net', and a bare string is
5559
5745
  // encoded as a plain dictionary token, so where every other client puts an
5560
5746
  // address the server was reading text. That is the whole of the difference,
@@ -1178,6 +1178,87 @@ class DeviceManager {
1178
1178
  }
1179
1179
 
1180
1180
  // ─── Invalidate cached device list (used on phash mismatch / 421 retry) ───
1181
+ /**
1182
+ * Apply a device-list change and check the result against the server's own
1183
+ * hash of what the list should now be.
1184
+ *
1185
+ * A `devices` notification says what changed — one device added, one removed —
1186
+ * and carries `device_hash`, the participant hash of the list the server
1187
+ * believes we should end up with. That hash is the thing worth having: it
1188
+ * turns "I applied the change, probably correctly" into something checkable.
1189
+ *
1190
+ * So the change is applied to the cached list, the hash of the result is
1191
+ * computed, and the two are compared. Equal, and the cache is provably right
1192
+ * and is kept. Different, and the cache is wrong in some way this side cannot
1193
+ * see, so it is thrown away and the next send asks usync for the whole list.
1194
+ *
1195
+ * That second branch is exactly what this did for every notification before —
1196
+ * drop everything, ask again. Which is safe, and costs a round trip each time
1197
+ * somebody opens WhatsApp Web. This keeps the cache when it can prove it is
1198
+ * right, and falls back to the old behaviour when it cannot.
1199
+ *
1200
+ * Safe by construction: if this hash were computed differently from the
1201
+ * server's, nothing would ever match and every notification would take the
1202
+ * fallback — which is where it started.
1203
+ *
1204
+ * Works for either half of a contact's identity. WhatsApp keeps a device list
1205
+ * under the phone number and another under the LID, hashes them separately,
1206
+ * and sends both hashes on the same notification — so each is verified on its
1207
+ * own, and one going wrong does not cost the other.
1208
+ *
1209
+ * @param {string} cacheKey the cache entry: digits, or 'lid:<user>'
1210
+ * @param {string} user the JID user part the ids belong to
1211
+ * @param {Array<{tag: string, deviceId: number}>} changes
1212
+ * @param {string} serverHash device_hash, or device_lid_hash
1213
+ * @param {string} [server] 's.whatsapp.net' (default) or 'lid'
1214
+ * @returns {boolean} whether the result verified and the cache was kept
1215
+ */
1216
+ verifyDeviceDelta(cacheKey, user, changes, serverHash, server) {
1217
+ server = server || 's.whatsapp.net';
1218
+ if (!cacheKey || !user || !serverHash) return false;
1219
+ if (!Array.isArray(changes) || !changes.length) return false;
1220
+
1221
+ // No baseline means there is nothing to apply a change to. Caching just the
1222
+ // one device named in the notification would make a list of one look
1223
+ // authoritative, which is how a recipient's other devices stop being
1224
+ // written to.
1225
+ const cached = this._dcGet(cacheKey);
1226
+ if (!cached || !cached.length) return false;
1227
+
1228
+ const next = new Set(cached);
1229
+ for (const { tag, deviceId } of changes) {
1230
+ if (!Number.isFinite(deviceId)) return false;
1231
+ if (tag === 'add') next.add(deviceId);
1232
+ else if (tag === 'remove') next.delete(deviceId);
1233
+ else return false; // 'update' and anything unknown: do not guess
1234
+ }
1235
+
1236
+ const ids = [...next].sort((a, b) => a - b);
1237
+ const jids = ids.map(d => makeDeviceJid(user, d, server));
1238
+
1239
+ let ours;
1240
+ try {
1241
+ // Lazily required: MessageSender requires this file, so taking it at the
1242
+ // top would close the cycle before either module finishes loading.
1243
+ const { computePhash } = require('./messages/MessageSender');
1244
+ ours = computePhash(jids);
1245
+ } catch (_) {
1246
+ return false;
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;
1254
+ }
1255
+
1256
+ this._dcSet(cacheKey, ids);
1257
+ _whaDbg('[DBG] DEV_HASH verified for ' + cacheKey + ' ids=[' + ids.join(',') +
1258
+ '] hash=' + ours);
1259
+ return true;
1260
+ }
1261
+
1181
1262
  clearCache(phones) {
1182
1263
  if (!phones || phones.length === 0) {
1183
1264
  this._dcFlush();
@@ -1187,10 +1268,13 @@ class DeviceManager {
1187
1268
  }
1188
1269
 
1189
1270
  // ─── Build participants node ───────────────────────────────────────────────
1190
- static buildParticipantsNode(encryptedList, mediaSubtype) {
1271
+ static buildParticipantsNode(encryptedList, mediaSubtype, decryptFail) {
1191
1272
  const toNodes = encryptedList.map(({ jid, type, ciphertext }) => {
1192
1273
  const encAttrs = { type, v: '2' };
1193
1274
  if (mediaSubtype) encAttrs.mediatype = mediaSubtype;
1275
+ // What the recipient shows if this payload will not decrypt — see
1276
+ // resolveDecryptFail() in MessageSender.
1277
+ if (decryptFail) encAttrs['decrypt-fail'] = decryptFail;
1194
1278
  // Always encode the jid as a binary JID object (JID_PAIR / AD_JID) so the
1195
1279
  // server can route the per-device enc payload to the correct Signal session.
1196
1280
  // Raw UTF-8 strings are silently dropped for @lid recipients.
package/lib/PlayStore.js CHANGED
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
- // Fetching the WhatsApp APK from Google Play, the way Cobalt's PlayStoreUtils
4
- // does it, so the Android token material can be refreshed without anyone having
5
- // to find an APK by hand.
3
+ // Fetching the WhatsApp APK from Google Play, so the Android token material can
4
+ // be refreshed without anyone having to find an APK by hand.
6
5
  //
7
6
  // The route, in three hops:
8
7
  //
@@ -252,7 +251,7 @@ async function acquire(packageName, versionCode, headers) {
252
251
  }, headers),
253
252
  body
254
253
  });
255
- } catch (_) { /* best effort, as in Cobalt */ }
254
+ } catch (_) { /* best effort */ }
256
255
  }
257
256
 
258
257
  // { baseUrl, splits: [{name, url}], cookies: {name: value} }
@@ -16,9 +16,9 @@ const { dbg: _whaDbg, warn: _whaWarn } = require('./logger');
16
16
  // ---------- Request envelope ----------
17
17
  //
18
18
  // The registration server reads which platform is calling out of the
19
- // User-Agent. There is no form field for it — neither this library nor Cobalt
20
- // sends one — so a request whose User-Agent does not name a platform it knows is
21
- // refused with {"param":"platform","reason":"bad_param"} on every endpoint. The
19
+ // User-Agent. There is no form field for it — none is sent — so a request whose
20
+ // User-Agent does not name a platform it knows is refused with
21
+ // {"param":"platform","reason":"bad_param"} on every endpoint. The
22
22
  // giveaway is that /client_log is refused the same way, and its body carries no
23
23
  // device fields at all: nothing about the platform is in the body to begin with.
24
24
  //
@@ -26,10 +26,9 @@ const { dbg: _whaDbg, warn: _whaWarn } = require('./logger');
26
26
  // was refused everywhere. iOS has always sent the full form and has always
27
27
  // worked, which is why only one of the two platforms was broken.
28
28
  //
29
- // The three extra Android headers come from Cobalt's AndroidClientRegistration,
30
- // which records them as observed on a live native Android registration. Its iOS
31
- // counterpart documents that the native iOS client omits all three, so the iOS
32
- // envelope stays exactly as it was.
29
+ // The three extra Android headers were observed on a live native Android
30
+ // registration. The native iOS client omits all three, so the iOS envelope
31
+ // stays exactly as it was.
33
32
  // The platform token in the User-Agent. The Business builds name themselves
34
33
  // differently — `SMBA` on Android, `SMB iOS` on iOS — and the registration
35
34
  // server reads the platform out of exactly this string.
@@ -628,12 +627,48 @@ function parsePhone(phoneNumber) {
628
627
  // Cached per variant: the consumer and Business builds ship on their own
629
628
  // schedules, and announcing one's version as the other is a version the server
630
629
  // has no record of for that platform.
630
+ //
631
+ // The entries expire. Without that the first answer of a process is its last:
632
+ // a session that reconnects for weeks would keep announcing whatever the store
633
+ // happened to say the morning it started, which is the same stale-version
634
+ // problem the refresh exists to solve. Six hours is short enough that a release
635
+ // is picked up the day it lands and long enough that a connection flapping
636
+ // every few seconds never reaches the network for it.
637
+ const VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
638
+
631
639
  const _cachedIosVersion = {};
632
640
  const _cachedAndroidVersion = {};
633
641
 
642
+ function _cachedVersion(cache, key) {
643
+ const entry = cache[key];
644
+ if (!entry) return null;
645
+ if (Date.now() - entry.at > VERSION_CACHE_TTL_MS) {
646
+ delete cache[key];
647
+ return null;
648
+ }
649
+ return entry.value;
650
+ }
651
+
652
+ function _cacheVersion(cache, key, value) {
653
+ if (value) cache[key] = { value, at: Date.now() };
654
+ return value;
655
+ }
656
+
657
+ /**
658
+ * Drop the memoised lookups so the next call goes to the network.
659
+ *
660
+ * Ordinary callers are served by the TTL above; this exists for tests and for
661
+ * anything that needs a definite answer right now.
662
+ */
663
+ function clearVersionCache() {
664
+ for (const k of Object.keys(_cachedIosVersion)) delete _cachedIosVersion[k];
665
+ for (const k of Object.keys(_cachedAndroidVersion)) delete _cachedAndroidVersion[k];
666
+ }
667
+
634
668
  async function fetchIosVersion(business) {
635
669
  const key = business ? 'business' : 'personal';
636
- if (_cachedIosVersion[key]) return _cachedIosVersion[key];
670
+ const hit = _cachedVersion(_cachedIosVersion, key);
671
+ if (hit) return hit;
637
672
  const bundleId = business ? IOS_BUSINESS_BUNDLE_ID : IOS_BUNDLE_ID;
638
673
  return new Promise((resolve) => {
639
674
  const req = https.get(
@@ -647,7 +682,7 @@ async function fetchIosVersion(business) {
647
682
  const json = JSON.parse(Buffer.concat(chunks).toString('utf8'));
648
683
  let ver = (json.results && json.results[0] && json.results[0].version) || IOS_VERSION_FALLBACK;
649
684
  if (!ver.startsWith('2.')) ver = '2.' + ver;
650
- _cachedIosVersion[key] = ver;
685
+ _cacheVersion(_cachedIosVersion, key, ver);
651
686
  resolve(ver);
652
687
  } catch (_) {
653
688
  resolve(IOS_VERSION_FALLBACK);
@@ -665,7 +700,8 @@ async function fetchIosVersion(business) {
665
700
  // Falls back to ANDROID_VERSION_FALLBACK on any error.
666
701
  async function fetchAndroidVersion(business) {
667
702
  const key = business ? 'business' : 'personal';
668
- if (_cachedAndroidVersion[key]) return _cachedAndroidVersion[key];
703
+ const hit = _cachedVersion(_cachedAndroidVersion, key);
704
+ if (hit) return hit;
669
705
  const packageName = business ? WHATSAPP_BUSINESS_PACKAGE : WHATSAPP_PACKAGE;
670
706
  try {
671
707
  const axios = require('axios');
@@ -698,15 +734,13 @@ async function fetchAndroidVersion(business) {
698
734
  // In Play Store JSON the current stable version appears first, before beta/history entries.
699
735
  const primary = html.match(/"(2\.\d+\.\d+\.\d+)"/);
700
736
  if (primary) {
701
- _cachedAndroidVersion[key] = primary[1];
702
- return primary[1];
737
+ return _cacheVersion(_cachedAndroidVersion, key, primary[1]);
703
738
  }
704
739
 
705
740
  // Secondary: unquoted version adjacent to "WhatsApp" text (catches alternate HTML structures).
706
741
  const secondary = html.match(/WhatsApp[^<"]{0,200}?(2\.\d+\.\d+\.\d+)/);
707
742
  if (secondary) {
708
- _cachedAndroidVersion[key] = secondary[1];
709
- return secondary[1];
743
+ return _cacheVersion(_cachedAndroidVersion, key, secondary[1]);
710
744
  }
711
745
 
712
746
  // Last resort: the highest 4-part version anywhere on the page. Play has
@@ -716,8 +750,7 @@ async function fetchAndroidVersion(business) {
716
750
  if (all && all.length) {
717
751
  const newest = all.sort(compareVersions).pop();
718
752
  _whaDbg('[DBG] ANDROID_VERSION scraped from an unrecognised page layout: ' + newest);
719
- _cachedAndroidVersion[key] = newest;
720
- return newest;
753
+ return _cacheVersion(_cachedAndroidVersion, key, newest);
721
754
  }
722
755
 
723
756
  _whaWarn('could not read the current WhatsApp version off the Play Store page — ' +
@@ -746,8 +779,8 @@ function compareVersions(a, b) {
746
779
  // Return the appropriate WhatsApp version for the active device.
747
780
  // If WA_VERSION is set in the environment, that value is always used.
748
781
  //
749
- // On Android the APK the token material came from decides it, the way Cobalt
750
- // announces the versionName of the APK it downloaded. The token is signed over
782
+ // On Android the APK the token material came from decides it: the versionName
783
+ // of the APK that was downloaded. The token is signed over
751
784
  // that build's classes.dex, so a version read from anywhere else describes a
752
785
  // different build than the one the token proves — which the server sees as a
753
786
  // token that does not belong to the client sending it. The live lookup stays as
@@ -1092,7 +1125,7 @@ function buildForm(pairs, extraPairs) {
1092
1125
  }
1093
1126
 
1094
1127
  // ---------- access_session_id ----------
1095
- // Cobalt's newAccessSessionId(): 16 random bytes → URL-safe base64 without
1128
+ // 16 random bytes → URL-safe base64 without
1096
1129
  // padding (a 22-char token). Generated once per store/session and reused on
1097
1130
  // every attested endpoint, matching the native client.
1098
1131
 
@@ -1106,13 +1139,11 @@ function getAccessSessionId(store) {
1106
1139
  return store._accessSessionId;
1107
1140
  }
1108
1141
 
1109
- // ---------- /code verification-code parameters (per platform, mirrors Cobalt) ----------
1142
+ // ---------- /code verification-code parameters (per platform) ----------
1110
1143
  // Returns the alternating name/value form fields the native client emits on a
1111
1144
  // /code request. Android sends the long device-fingerprint list; iOS sends the
1112
- // short list. Values match AndroidClientRegistration / IosClientRegistration;
1113
- // sim_mcc/sim_mnc/mcc/mnc keep whalibmob's real per-country values (better than
1114
- // Cobalt's "000" placeholder), and advertising_id / backup_token come from the
1115
- // store.
1145
+ // short list. sim_mcc/sim_mnc/mcc/mnc carry real per-country values rather than
1146
+ // a "000" placeholder, and advertising_id / backup_token come from the store.
1116
1147
 
1117
1148
  // How long the server said to wait, in seconds, or null when it did not say.
1118
1149
  //
@@ -1293,8 +1324,8 @@ async function buildPayload(store, waVersion, useToken, extraPairs) {
1293
1324
  ? store.fdid.toLowerCase()
1294
1325
  : store.fdid.toUpperCase();
1295
1326
 
1296
- // Device-attestation fields shipped on every attested endpoint (Cobalt
1297
- // attestationFields). Empty by default (NONE fallback); populated when a
1327
+ // Device-attestation fields shipped on every attested endpoint.
1328
+ // Empty by default (NONE fallback); populated when a
1298
1329
  // Frida attestation server is configured via WA_FRIDA_HOST. The Play
1299
1330
  // Integrity nonce is a stable per-session value derived from the store.
1300
1331
  const nonceB64 = attestation.toBase64Url(
@@ -2137,7 +2168,7 @@ async function verifyCode(store, code, opts) {
2137
2168
  throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
2138
2169
  }
2139
2170
 
2140
- module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, currentVersionFor, refreshSessionVersion, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
2171
+ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, currentVersionFor, refreshSessionVersion, clearVersionCache, tryLoadAndroidMaterial, compareVersions, parsePhone, getCountryMeta, assertRegistrationKeys, parseSocksProxy, socksProxyUrl };
2141
2172
 
2142
2173
  // The HTTP reader, exposed for tests. Not part of the public API.
2143
2174
  module.exports._http = { readHttpResponse, parseHttpResponse, decodeChunkedBody };
@@ -124,6 +124,35 @@ function _getCurve() {
124
124
  // Build ADVSignedDeviceIdentity for our primary iOS device.
125
125
  // Returns Buffer of encoded proto WITH accountSignatureKey included.
126
126
  // Result is cached in store.advIdentity so we only build it once per registration.
127
+ // What the recipient's client should do when one of our payloads will not
128
+ // decrypt for them.
129
+ //
130
+ // The attribute rides on the <enc> node and is read on the other side, not by
131
+ // the server. Absent, a failure draws the "Waiting for this message" placeholder
132
+ // in their chat. `hide` draws nothing at all.
133
+ //
134
+ // That placeholder is right for a real message — the person can see something
135
+ // arrived and ask for it again. It is nonsense for the three kinds below, which
136
+ // are invisible operations on a message that is already there: a reaction that
137
+ // will not decrypt is a heart that never appears, not a message to wait for, and
138
+ // a failed revoke or edit would leave a ghost bubble referring to nothing.
139
+ //
140
+ // The rule is the reference clients' rule, narrowed to what this library sends:
141
+ //
142
+ // reaction sendReaction() → stanza type 'reaction'
143
+ // edit editMessage() → edit bit '1'
144
+ // revoke deleteMessage(forEveryone: true) → edit bit '7' or '8'
145
+ //
146
+ // Everything else gets nothing, which is the same as saying "show it".
147
+ function resolveDecryptFail(mediaType, options) {
148
+ if (mediaType === 'reaction') return 'hide';
149
+ // Any edit bit: 1 is an edit, 7 and 8 are the two revoke forms. The reference
150
+ // client keys off the presence of the attribute rather than its value, and so
151
+ // does this.
152
+ if (options && options.edit) return 'hide';
153
+ return null;
154
+ }
155
+
127
156
  function _buildOrGetAdvIdentity(store) {
128
157
  // Only ever return an identity the server actually issued.
129
158
  //
@@ -909,6 +938,7 @@ class MessageSender {
909
938
  const ownPhone = String(this._store.phoneNumber);
910
939
  const ownMainJid = `${ownPhone}@s.whatsapp.net`;
911
940
  const mediaSubtype = options._mediaSubtype || null;
941
+ const decryptFail = resolveDecryptFail(mediaType, options);
912
942
 
913
943
  // For primary iOS devices (registered via SMS), advIdentity is never set
914
944
  // because the server only sends device-identity for linked secondary devices.
@@ -1040,7 +1070,8 @@ class MessageSender {
1040
1070
  // Always use <participants><to jid=...><enc> structure — required by MD protocol.
1041
1071
  // Old iOS non-MD used direct <enc> in <message>, but the WhatsApp server now
1042
1072
  // enforces the MD participants wrapper even for single-device sends.
1043
- const participantsNode = DeviceManager.buildParticipantsNode(encryptedList, mediaSubtype);
1073
+ const participantsNode = DeviceManager.buildParticipantsNode(
1074
+ encryptedList, mediaSubtype, decryptFail);
1044
1075
  msgContent = [...devIdNodes, participantsNode];
1045
1076
  }
1046
1077
 
@@ -1159,6 +1190,7 @@ class MessageSender {
1159
1190
  const ownPhone = String(this._store.phoneNumber);
1160
1191
  const ownJid = `${ownPhone}@s.whatsapp.net`;
1161
1192
  const mediaSubtype = options._mediaSubtype || null;
1193
+ const decryptFail = resolveDecryptFail(mediaType, options);
1162
1194
 
1163
1195
  const isStatus = isStatusBroadcast(groupJid);
1164
1196
 
@@ -1405,10 +1437,17 @@ class MessageSender {
1405
1437
  // participant nodes as on the skmsg, so an image sent to a group carries
1406
1438
  // mediatype on both. Passing null here left the SKDM <enc> nodes
1407
1439
  // unlabelled.
1408
- msgContent.push(DeviceManager.buildParticipantsNode(skdmEncrypted, mediaSubtype));
1440
+ msgContent.push(DeviceManager.buildParticipantsNode(
1441
+ skdmEncrypted, mediaSubtype, decryptFail));
1409
1442
  }
1410
1443
  const skmsgAttrs = { type: 'skmsg', v: '2' };
1411
1444
  if (mediaSubtype) skmsgAttrs.mediatype = mediaSubtype;
1445
+ // On the skmsg too, and that is where it matters most in a group: the
1446
+ // participant nodes carry the sender-key distribution, but the reaction
1447
+ // itself travels in here. The two reference clients disagree — one sets it
1448
+ // on both, the other only on the participant nodes — and the one that sets
1449
+ // both is the one whose reasoning holds up.
1450
+ if (decryptFail) skmsgAttrs['decrypt-fail'] = decryptFail;
1412
1451
  msgContent.push(new BinaryNode('enc', skmsgAttrs, Buffer.isBuffer(skmsgCiphertext) ? skmsgCiphertext : Buffer.from(skmsgCiphertext)));
1413
1452
 
1414
1453
  // Same reporting token a direct message carries. A poll posted to a group
@@ -41,7 +41,7 @@ const DEBOUNCE_MS = 300;
41
41
  // ─── Pre-key files ────────────────────────────────────────────────────────────
42
42
  //
43
43
  // One-time pre-keys do not live in the snapshot above. They get a file each,
44
- // named the way Baileys names them:
44
+ // named one per id:
45
45
  //
46
46
  // <base>/40756469325/40756469325.pre-key-1.json
47
47
  // ...
@@ -60,8 +60,8 @@ const DEBOUNCE_MS = 300;
60
60
  // must — and a session still sitting in the old flat layout, where every
61
61
  // number shares one directory, does not collide with its neighbours either.
62
62
  //
63
- // A pre-key on disk is byte-for-byte what Baileys writes: BufferJSON, the raw
64
- // 32-byte public key with no type prefix.
63
+ // A pre-key on disk is BufferJSON: the raw 32-byte public key with no type
64
+ // prefix.
65
65
  //
66
66
  // {"private":{"type":"Buffer","data":"…"},"public":{"type":"Buffer","data":"…"}}
67
67
  //
@@ -162,7 +162,7 @@ class SignalStore {
162
162
  this._pkTimer = null;
163
163
  this._pkWriting = false;
164
164
 
165
- // The two counters Baileys keeps in creds. They live here instead because a
165
+ // The two pre-key counters. They live here rather than in creds because a
166
166
  // number's mobile and companion halves have a .signal.json each and must
167
167
  // never share a pre-key id space.
168
168
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.17.0",
3
+ "version": "5.19.0",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",