whalibmob 5.18.0 → 5.19.1
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 +40 -23
- package/lib/AndroidApk.js +11 -14
- package/lib/Attestation.js +3 -4
- package/lib/Client.js +282 -51
- package/lib/DeviceManager.js +19 -10
- package/lib/PlayStore.js +3 -4
- package/lib/Registration.js +58 -27
- package/lib/messages/MessageSender.js +32 -6
- package/lib/signal/SignalStore.js +4 -4
- package/package.json +1 -1
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
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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.
|
|
@@ -2007,10 +2007,11 @@ Every option is optional; `sessionDir` is the only one most senders ever set.
|
|
|
2007
2007
|
| `sessionDir` | `~/.waSession` | The authentication folder. Each number gets its own subfolder inside it — see [Saving & Restoring Sessions](#saving--restoring-sessions). |
|
|
2008
2008
|
| `autoFixNumber` | `true` | Re-file the session automatically when the server reports the account under a different number. Set `false` to be told instead of fixed — see [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under). |
|
|
2009
2009
|
| `autoRead` | `true` | Send read receipts for incoming messages. `false` leaves them unread. |
|
|
2010
|
-
| `refreshVersion` | `true` |
|
|
2010
|
+
| `refreshVersion` | `true` | Check the platform's store for the current build on every connect and reconnect, and write it into the session file. Both iOS and Android. Set `false` to keep announcing whatever the session registered with — see [Keeping the Announced Version Current](#keeping-the-announced-version-current). |
|
|
2011
2011
|
| `pino` | off | Debug logging. `true` turns it on at `debug` level; an object is passed to `pino` as-is. |
|
|
2012
2012
|
| `sentCacheSize` | `2000` | How many sent messages keep their plaintext so a retry receipt naming them can be answered. |
|
|
2013
2013
|
| `maxRetryResends` | `5` | How many times one message may be re-sent in answer to retry receipts before the client gives up. |
|
|
2014
|
+
| `tcTokenPresendTimeoutMs` | `5000` | How long the first message to a new contact waits for its trusted-contact token before going out without one — see [tcToken — Error 463 Defense](#tctoken--error-463-defense). `0` sends immediately and lets the token arrive for the next message. |
|
|
2014
2015
|
|
|
2015
2016
|
```js
|
|
2016
2017
|
const client = new WhalibmobClient({
|
|
@@ -2322,6 +2323,8 @@ Every event from the SMS primary API fires here too — `message`, `receipt`, `p
|
|
|
2322
2323
|
| `history_sync` | `{ syncTypeName, chats, contacts, pushNames, merged }` | a chunk of history arrived |
|
|
2323
2324
|
| `history_sync_error` | `{ err, notification }` | a chunk could not be fetched or decrypted |
|
|
2324
2325
|
| `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. |
|
|
2326
|
+
| `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 |
|
|
2327
|
+
| `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
2328
|
|
|
2326
2329
|
### Reading What the Phone Sent
|
|
2327
2330
|
|
|
@@ -2585,40 +2588,53 @@ A session records the version it registered with. Left alone it announces that
|
|
|
2585
2588
|
same number forever, so a number registered in spring is still claiming a spring
|
|
2586
2589
|
build in autumn, and one day the connect simply stops working.
|
|
2587
2590
|
|
|
2588
|
-
**
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
+
**Sessions refresh themselves, on both platforms.** Before every handshake the
|
|
2592
|
+
client asks the platform's store what the current build is and writes it into
|
|
2593
|
+
`<phone>.json`. iOS reads the App Store listing, Android the Play Store one.
|
|
2594
|
+
Nothing to run, nothing to remember:
|
|
2591
2595
|
|
|
2592
2596
|
```js
|
|
2593
|
-
client.on('version_update', ({ from, to }) => {
|
|
2594
|
-
console.log('announcing', to, 'instead of', from)
|
|
2597
|
+
client.on('version_update', ({ from, to, source }) => {
|
|
2598
|
+
console.log('announcing', to, 'instead of', from, '—', source)
|
|
2595
2599
|
})
|
|
2596
2600
|
|
|
2597
2601
|
await client.init('40756469325')
|
|
2598
2602
|
```
|
|
2599
2603
|
|
|
2600
|
-
|
|
2604
|
+
**Reconnects are covered too.** The check runs from the socket-connect step
|
|
2605
|
+
rather than from `init()`, so the library's own reconnect loop passes through it
|
|
2606
|
+
as well. A bot that stays up for weeks does not go on announcing whatever the
|
|
2607
|
+
listing said the morning it started. The handshake reads the version out of the
|
|
2608
|
+
store at the moment it runs, so a version written here is the one that goes out.
|
|
2601
2609
|
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2610
|
+
Four rules keep this from being the thing that breaks a working session:
|
|
2611
|
+
|
|
2612
|
+
- **It never goes backwards.** Both lookups answer with a pinned fallback rather
|
|
2613
|
+
than failing when they cannot reach the network, and that fallback can easily
|
|
2614
|
+
be older than what the session holds. Moving a session to an older version is
|
|
2615
|
+
the one outcome that makes a `405` *more* likely.
|
|
2606
2616
|
- **`WA_VERSION` still wins.** A version pinned on the way out of a `405` is a
|
|
2607
2617
|
decision, and is never quietly replaced.
|
|
2608
2618
|
- **A failed lookup changes nothing.** The session keeps the version it has and
|
|
2609
2619
|
the connect carries on.
|
|
2620
|
+
- **The network is not asked twice for the same answer.** Each lookup is
|
|
2621
|
+
memoised for six hours, so a connection that flaps every few seconds reaches
|
|
2622
|
+
the store once rather than once per attempt — and a session up for a month
|
|
2623
|
+
still picks up a release the day it lands.
|
|
2610
2624
|
|
|
2611
2625
|
Set `{ refreshVersion: false }` on the client to turn it off.
|
|
2612
2626
|
|
|
2613
2627
|
> [!NOTE]
|
|
2614
|
-
> **
|
|
2615
|
-
>
|
|
2616
|
-
> token is
|
|
2617
|
-
>
|
|
2618
|
-
>
|
|
2628
|
+
> **Registration still reads the APK.** A number being registered announces the
|
|
2629
|
+
> `versionName` of the APK its token material came from, because the Android
|
|
2630
|
+
> registration token is signed over that build. Only the *announced* version on
|
|
2631
|
+
> an already-registered session follows the Play Store listing — no token
|
|
2632
|
+
> travels in the handshake, so there is nothing there for it to fall out of step
|
|
2633
|
+
> with. When the listing moves past the cached material the client emits
|
|
2634
|
+
> `apk_material_stale`, which is the cue to run `wa apk-material --download`
|
|
2635
|
+
> before registering the next number.
|
|
2619
2636
|
|
|
2620
|
-
The manual tool still works on both platforms
|
|
2621
|
-
Android session:
|
|
2637
|
+
The manual tool still works on both platforms:
|
|
2622
2638
|
|
|
2623
2639
|
```bash
|
|
2624
2640
|
wa refresh-version 40756469325 # current build for the platform
|
|
@@ -3263,8 +3279,9 @@ whalibmob implements the full lifecycle to prevent this:
|
|
|
3263
3279
|
| Step | What the library does automatically |
|
|
3264
3280
|
|---|---|
|
|
3265
3281
|
| **History seed** | On every history sync chunk, `tcToken` bytes are extracted from each conversation in the protobuf and loaded into `TcTokenStore` in memory. The first send after reconnect already has a valid token ready — no 463 risk on cold start. |
|
|
3282
|
+
| **First contact** | The very first DM to a contact has no token on file yet. Before the stanza is built, the library issues one and waits for it, so that message carries a `<tctoken>` like every later one instead of counting as an anonymous reach-out. The wait is bounded by `tcTokenPresendTimeoutMs` (default 5 s) — past it the message goes out regardless and the token lands in time for the next one. |
|
|
3266
3283
|
| **Attach on send** | Before dispatching any DM, `MessageSender` looks up the token for the recipient JID, checks it has not expired (28-day rolling window), and pushes a `<tctoken>` child node into the message stanza. |
|
|
3267
|
-
| **
|
|
3284
|
+
| **Renewal** | After a successful DM send, the library fires a `<iq type='set' xmlns='privacy'>` requesting a fresh token for that JID from the server — once per 7-day bucket, deduplicated in-flight. This one is fire-and-forget: the token being attached right now is still valid, so nothing waits for it. |
|
|
3268
3285
|
| **Incoming notification** | When a contact starts a new conversation, WhatsApp pushes a `<notification type='privacy_token'>`. The library catches it in `_handlePrivacyTokenNotification` and stores the token immediately. |
|
|
3269
3286
|
| **Identity change re-issue** | When decrypting a `pkmsg` (new Signal session from peer), the library calls `_reissueTcTokenAfterIdentityChange` to re-issue the token for the new session. |
|
|
3270
3287
|
| **Error 463 recovery** | If a send fails with error 463, the library issues a fresh token, waits for the server response, and automatically retries the same message with the new token attached. |
|
|
@@ -4867,7 +4884,7 @@ come from:
|
|
|
4867
4884
|
| order | where the announced version comes from |
|
|
4868
4885
|
|---|---|
|
|
4869
4886
|
| 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 —
|
|
4887
|
+
| 2 | the version stored in the session file — refreshed from the platform's store before every handshake, unless `{ refreshVersion: false }` |
|
|
4871
4888
|
|
|
4872
4889
|
Almost every 405 is the first line winning when nobody meant it to.
|
|
4873
4890
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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".
|
|
272
|
-
//
|
|
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
|
|
299
|
-
//
|
|
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
|
-
//
|
|
359
|
-
//
|
|
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
|
|
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 {
|
package/lib/Attestation.js
CHANGED
|
@@ -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
|
|
8
|
-
// same scripts.
|
|
7
|
+
// into the registration request.
|
|
9
8
|
//
|
|
10
|
-
// Design
|
|
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
|
|
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');
|
|
@@ -79,6 +79,12 @@ const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
|
|
|
79
79
|
// server takes a longer one, answers <iq type="result"/> and keeps the old text.
|
|
80
80
|
const ABOUT_MAX_LENGTH = 139;
|
|
81
81
|
|
|
82
|
+
// How long the first message to a contact waits for its trusted-contact token.
|
|
83
|
+
// One round-trip to the server, no more: past this the message goes out without
|
|
84
|
+
// a token rather than sitting in the queue, and the issuance finishes in the
|
|
85
|
+
// background for the next send.
|
|
86
|
+
const TC_TOKEN_PRESEND_TIMEOUT_MS = 5000;
|
|
87
|
+
|
|
82
88
|
// The names privacy settings actually go by on the wire, and the friendly
|
|
83
89
|
// aliases this library has always accepted for them. The wire names are the
|
|
84
90
|
// right-hand column.
|
|
@@ -379,12 +385,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
379
385
|
// automatically on the first refused login. Set { autoFixNumber: false } to
|
|
380
386
|
// be told about it instead.
|
|
381
387
|
this._autoFixNumber = opts.autoFixNumber !== false;
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// deliberate step.
|
|
388
|
+
// A session checks its platform's store for the current build before every
|
|
389
|
+
// handshake and writes it back — see _refreshAnnouncedVersion(). iOS reads
|
|
390
|
+
// the App Store listing, Android the Play Store one, and reconnects are
|
|
391
|
+
// covered as well as the first connect. Set { refreshVersion: false } to
|
|
392
|
+
// keep announcing whatever the session registered with.
|
|
388
393
|
this._refreshVersion = opts.refreshVersion !== false;
|
|
389
394
|
this._numberFixAttempted = false;
|
|
390
395
|
this._pingTimer = null;
|
|
@@ -465,6 +470,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
465
470
|
this._tcTokenStore = null; // TcTokenStore — loaded in init()
|
|
466
471
|
this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
|
|
467
472
|
this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
|
|
473
|
+
// jid → in-flight pre-send issuance promise. A Set would only say that one
|
|
474
|
+
// is running; concurrent first sends to the same contact have to be able to
|
|
475
|
+
// wait for the same answer, so the promise itself is what gets shared.
|
|
476
|
+
this._tcTokenIssuanceWaiters = new Map();
|
|
477
|
+
// How long a first send will wait for its token before going out without
|
|
478
|
+
// one. _sendIq gives up after 15s, which is far too long to hold a message.
|
|
479
|
+
this._tcTokenPresendTimeoutMs =
|
|
480
|
+
Number(opts.tcTokenPresendTimeoutMs) >= 0
|
|
481
|
+
? Number(opts.tcTokenPresendTimeoutMs) : TC_TOKEN_PRESEND_TIMEOUT_MS;
|
|
468
482
|
// Last known account restriction, or null until one is fetched or pushed.
|
|
469
483
|
this._reachoutTimelock = null;
|
|
470
484
|
this._reachoutTimelockInFlight = null;
|
|
@@ -505,7 +519,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
505
519
|
}
|
|
506
520
|
|
|
507
521
|
/**
|
|
508
|
-
* Bring
|
|
522
|
+
* Bring a session's announced version up to date, before every handshake.
|
|
509
523
|
*
|
|
510
524
|
* A session records the version it registered with and announces that
|
|
511
525
|
* forever after. Months later the server stops accepting it and the connect
|
|
@@ -515,56 +529,111 @@ class WhalibmobClient extends EventEmitter {
|
|
|
515
529
|
* connectWeb() reads the live revision every time; this is the same idea for
|
|
516
530
|
* the primary one.
|
|
517
531
|
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
532
|
+
* Both platforms, and the version each one asks for is the one that platform
|
|
533
|
+
* publishes: the App Store listing for iOS, the Play Store listing for
|
|
534
|
+
* Android.
|
|
535
|
+
*
|
|
536
|
+
* Android was left out of this at first, on the grounds that its version has
|
|
537
|
+
* to match the APK the token material came from. That holds for registering a
|
|
538
|
+
* number and nowhere else. The Android token is an HMAC over the APK's
|
|
539
|
+
* signing certificates, the digest of its classes.dex and the national
|
|
540
|
+
* number — the version string is not in it — and no token of any kind travels
|
|
541
|
+
* in the handshake, which carries the version, the username and the device
|
|
542
|
+
* properties. So there is nothing here for a refreshed version to fall out of
|
|
543
|
+
* step with. Registration keeps reading the APK's own version through
|
|
544
|
+
* fetchWaVersion(), so a number being registered still announces the build
|
|
545
|
+
* its token was computed from.
|
|
546
|
+
*
|
|
547
|
+
* Called from _connectSocket() rather than init(), so it covers the
|
|
548
|
+
* library's own reconnects too. The handshake reads store.version at the
|
|
549
|
+
* moment it runs, so a version written here is the one that goes out.
|
|
523
550
|
*
|
|
524
551
|
* Three rules keep this from being the thing that breaks a working session:
|
|
525
552
|
*
|
|
526
|
-
* • It never goes backwards.
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* likely rather than less.
|
|
553
|
+
* • It never goes backwards. Both lookups answer with a pinned fallback
|
|
554
|
+
* rather than failing, and that fallback can easily be older than what a
|
|
555
|
+
* session already holds. Moving a session to an older version is the one
|
|
556
|
+
* outcome that makes a 405 more likely rather than less.
|
|
531
557
|
* • WA_VERSION still wins. Someone who has pinned a version on the way out
|
|
532
558
|
* of a 405 must not have it quietly replaced.
|
|
533
559
|
* • 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.
|
|
560
|
+
* leaves the session exactly as it was and the connect carries on. The
|
|
561
|
+
* lookups memoise for six hours, so a connection that flaps every few
|
|
562
|
+
* seconds reaches the network once, not once per attempt.
|
|
535
563
|
*/
|
|
536
|
-
async
|
|
564
|
+
async _refreshAnnouncedVersion() {
|
|
537
565
|
if (!this._refreshVersion || this._mode === 'web' || !this._store) return;
|
|
538
566
|
if (process.env.WA_VERSION) {
|
|
539
567
|
_whaDbg('[DBG] VERSION refresh skipped — WA_VERSION is set');
|
|
540
568
|
return;
|
|
541
569
|
}
|
|
542
570
|
|
|
543
|
-
const device
|
|
544
|
-
|
|
571
|
+
const device = this._store.device || getDeviceConfig();
|
|
572
|
+
const business = !!device.business;
|
|
573
|
+
const android = device.os === 'android';
|
|
574
|
+
const source = android ? 'Play Store listing' : 'App Store listing';
|
|
545
575
|
|
|
546
576
|
try {
|
|
547
577
|
const { compareVersions } = require('./Registration');
|
|
548
578
|
const before = this._store.version;
|
|
549
|
-
const live =
|
|
579
|
+
const live = android
|
|
580
|
+
? await fetchAndroidVersion(business)
|
|
581
|
+
: await fetchIosVersion(business);
|
|
550
582
|
if (!live) return;
|
|
551
583
|
|
|
552
584
|
if (before && compareVersions(live, before) <= 0) {
|
|
553
|
-
_whaDbg('[DBG] VERSION already current (' + before + ',
|
|
585
|
+
_whaDbg('[DBG] VERSION already current (' + before + ', ' + source +
|
|
586
|
+
' says ' + live + ')');
|
|
554
587
|
return;
|
|
555
588
|
}
|
|
556
589
|
|
|
557
590
|
this._store.version = live;
|
|
558
591
|
this._persistStore();
|
|
559
592
|
_whaDbg('[DBG] VERSION refreshed ' + (before || 'none') + ' → ' + live +
|
|
560
|
-
' (
|
|
561
|
-
this.emit('version_update', { from: before || null, to: live, source
|
|
593
|
+
' (' + source + ')');
|
|
594
|
+
this.emit('version_update', { from: before || null, to: live, source });
|
|
595
|
+
|
|
596
|
+
// On Android the token material carries the version of the APK it was
|
|
597
|
+
// read from. Once the listing has moved past it, that material is from an
|
|
598
|
+
// older build — which matters for registering a number, not for this
|
|
599
|
+
// connection. Say so rather than fetching an APK on the connect path.
|
|
600
|
+
if (android) this._warnIfApkMaterialStale(device, live);
|
|
562
601
|
} catch (err) {
|
|
563
602
|
// A session that cannot ask keeps the version it has.
|
|
564
603
|
_whaDbg('[DBG] VERSION refresh failed: ' + (err && err.message));
|
|
565
604
|
}
|
|
566
605
|
}
|
|
567
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Note that the cached APK material predates the build Play is serving.
|
|
609
|
+
*
|
|
610
|
+
* Only registration reads that material, so nothing is broken here and
|
|
611
|
+
* nothing is downloaded. It is said once per connection so that a number
|
|
612
|
+
* registered from this install goes out under a current build if the owner
|
|
613
|
+
* refreshes the material first.
|
|
614
|
+
*/
|
|
615
|
+
_warnIfApkMaterialStale(device, liveVersion) {
|
|
616
|
+
try {
|
|
617
|
+
const { compareVersions, tryLoadAndroidMaterial } = require('./Registration');
|
|
618
|
+
if (typeof tryLoadAndroidMaterial !== 'function') return;
|
|
619
|
+
|
|
620
|
+
const material = tryLoadAndroidMaterial(device);
|
|
621
|
+
const apkVersion = material && material.apkVersion;
|
|
622
|
+
if (!apkVersion) return;
|
|
623
|
+
if (compareVersions(liveVersion, apkVersion) <= 0) return;
|
|
624
|
+
|
|
625
|
+
_whaDbg('[DBG] APK material is from ' + apkVersion + ', the listing serves ' +
|
|
626
|
+
liveVersion + ' — run `wa apk-material --download` before registering a number');
|
|
627
|
+
this.emit('apk_material_stale', {
|
|
628
|
+
materialVersion: apkVersion,
|
|
629
|
+
liveVersion,
|
|
630
|
+
hint: 'wa apk-material --download'
|
|
631
|
+
});
|
|
632
|
+
} catch (_) {
|
|
633
|
+
// Advisory only.
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
568
637
|
async init(phoneNumber) {
|
|
569
638
|
phoneNumber = String(phoneNumber).replace(/\D/g, '');
|
|
570
639
|
this._phoneNumber = phoneNumber;
|
|
@@ -594,10 +663,6 @@ class WhalibmobClient extends EventEmitter {
|
|
|
594
663
|
);
|
|
595
664
|
}
|
|
596
665
|
|
|
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
666
|
const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
|
|
602
667
|
const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
|
|
603
668
|
const tcTokenFile = path.join(this._sessionDir, `${phoneNumber}.tctoken.json`);
|
|
@@ -702,6 +767,15 @@ class WhalibmobClient extends EventEmitter {
|
|
|
702
767
|
async _connectSocket() {
|
|
703
768
|
if (this._mode === 'web') return this._connectWebSocket();
|
|
704
769
|
|
|
770
|
+
// Before the handshake, which reads the version straight out of the store.
|
|
771
|
+
//
|
|
772
|
+
// Here rather than in init() so that the library's own reconnects get it
|
|
773
|
+
// too: _scheduleReconnect() calls this method directly, and a session that
|
|
774
|
+
// refreshed only at startup would announce whatever the listing said the
|
|
775
|
+
// day the process began, however long it then stayed up. The lookups
|
|
776
|
+
// memoise for six hours, so a flapping connection costs nothing.
|
|
777
|
+
await this._refreshAnnouncedVersion();
|
|
778
|
+
|
|
705
779
|
const socket = new NoiseSocket(this._store);
|
|
706
780
|
this._socket = socket;
|
|
707
781
|
|
|
@@ -3433,16 +3507,51 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3433
3507
|
const isLid = fromJid.endsWith('@lid');
|
|
3434
3508
|
const mappedPn = isLid && this._lidToPn ? this._lidToPn.get(fromUser) : null;
|
|
3435
3509
|
const fromPhone = isLid ? mappedPn : fromUser;
|
|
3510
|
+
|
|
3511
|
+
// A contact has two device lists — one under the phone number, one under
|
|
3512
|
+
// the LID — and the notification carries both: `device_hash` for the list
|
|
3513
|
+
// it is addressed under, `device_lid_hash` for the other one, with each
|
|
3514
|
+
// <device> naming its jid and its lid. The server hashes them separately,
|
|
3515
|
+
// so they are checked separately, and one going wrong does not cost the
|
|
3516
|
+
// other. That matters most for the LID half: a LID cache thrown away for
|
|
3517
|
+
// a contact whose number we do not know cannot be refilled by usync,
|
|
3518
|
+
// which only answers questions about phone numbers.
|
|
3519
|
+
const lidAttr = String(attrs.lid || '');
|
|
3520
|
+
const lidUser = isLid ? fromUser
|
|
3521
|
+
: (lidAttr ? lidAttr.split('@')[0].split(':')[0] : null);
|
|
3522
|
+
// Free mapping, exactly as the notification states it.
|
|
3523
|
+
if (!isLid && lidUser && fromUser) {
|
|
3524
|
+
if (this._lidToPn) this._lidToPn.set(lidUser, fromUser);
|
|
3525
|
+
if (this._pnToLid) this._pnToLid.set(fromUser, lidUser);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3436
3528
|
// Before throwing the cache away: the notification says what changed and
|
|
3437
3529
|
// carries the hash of the list it should produce. Apply it, check the
|
|
3438
3530
|
// hash, and if it matches there is nothing to invalidate and nothing to
|
|
3439
3531
|
// ask usync about — see DeviceManager.verifyDeviceDelta().
|
|
3440
|
-
if (
|
|
3532
|
+
if (fromUser && this._devMgr) {
|
|
3441
3533
|
const delta = this._parseDeviceDelta(node);
|
|
3442
|
-
if (delta
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3534
|
+
if (delta) {
|
|
3535
|
+
// The primary half is keyed the way the notification is addressed.
|
|
3536
|
+
const priKey = isLid ? 'lid:' + fromUser : fromUser;
|
|
3537
|
+
const priServer = isLid ? 'lid' : 's.whatsapp.net';
|
|
3538
|
+
const priOk = this._devMgr.verifyDeviceDelta(
|
|
3539
|
+
priKey, fromUser, delta.changes, delta.hash, priServer);
|
|
3540
|
+
|
|
3541
|
+
// The secondary half only exists when the notification arrived under
|
|
3542
|
+
// the number and named the LID alongside it.
|
|
3543
|
+
if (!isLid && lidUser && delta.lidHash && delta.lidChanges) {
|
|
3544
|
+
const lidOk = this._devMgr.verifyDeviceDelta(
|
|
3545
|
+
'lid:' + lidUser, lidUser, delta.lidChanges, delta.lidHash, 'lid');
|
|
3546
|
+
_whaDbg('[DBG] NOTIF_DEVICES lid delta ' +
|
|
3547
|
+
(lidOk ? 'verified' : 'unverified') + ' for lid:' + lidUser);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
if (priOk) {
|
|
3551
|
+
_whaDbg('[DBG] NOTIF_DEVICES delta verified for ' + priKey +
|
|
3552
|
+
' — cache kept, no usync needed');
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3446
3555
|
}
|
|
3447
3556
|
}
|
|
3448
3557
|
|
|
@@ -3512,13 +3621,34 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3512
3621
|
* notification carrying two different ones is describing intermediate states
|
|
3513
3622
|
* this cannot reconstruct.
|
|
3514
3623
|
*
|
|
3515
|
-
*
|
|
3624
|
+
* Each child may also carry `device_lid_hash`, and each `<device>` a `lid`
|
|
3625
|
+
* beside its `jid`, describing the same change to the contact's LID device
|
|
3626
|
+
* list. That half is optional: missing on any child, and only the phone-number
|
|
3627
|
+
* half is returned, which is what the caller then verifies. It is never a
|
|
3628
|
+
* reason to reject the notification outright — the two lists are hashed
|
|
3629
|
+
* independently by the server and are checked independently here.
|
|
3630
|
+
*
|
|
3631
|
+
* @returns {{changes: Array<{tag, deviceId}>, hash: string,
|
|
3632
|
+
* lidChanges: Array<{tag, deviceId}>|null, lidHash: string|null}|null}
|
|
3516
3633
|
*/
|
|
3517
3634
|
_parseDeviceDelta(node) {
|
|
3518
3635
|
if (!node || !Array.isArray(node.content)) return null;
|
|
3519
3636
|
|
|
3520
|
-
const changes
|
|
3521
|
-
|
|
3637
|
+
const changes = [];
|
|
3638
|
+
const lidChanges = [];
|
|
3639
|
+
let hash = null;
|
|
3640
|
+
let lidHash = null;
|
|
3641
|
+
let lidOk = true; // every child so far carried a usable LID half
|
|
3642
|
+
|
|
3643
|
+
// "user:3@server" / "user@server" → 3 / 0, or null when unreadable.
|
|
3644
|
+
const deviceIdOf = (jid) => {
|
|
3645
|
+
if (!jid) return null;
|
|
3646
|
+
const raw = String(jid).split('@')[0];
|
|
3647
|
+
const colon = raw.indexOf(':');
|
|
3648
|
+
if (colon < 0) return 0;
|
|
3649
|
+
const device = parseInt(raw.slice(colon + 1), 10);
|
|
3650
|
+
return Number.isFinite(device) ? device : null;
|
|
3651
|
+
};
|
|
3522
3652
|
|
|
3523
3653
|
for (const child of node.content) {
|
|
3524
3654
|
if (!child || !child.description) continue;
|
|
@@ -3531,18 +3661,29 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3531
3661
|
else if (hash !== String(childHash)) return null; // disagreeing children
|
|
3532
3662
|
|
|
3533
3663
|
const deviceNode = findChild(child, 'device');
|
|
3534
|
-
const
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
const raw = String(jid).split('@')[0];
|
|
3538
|
-
const colon = raw.indexOf(':');
|
|
3539
|
-
const device = colon >= 0 ? parseInt(raw.slice(colon + 1), 10) : 0;
|
|
3540
|
-
if (!Number.isFinite(device)) return null;
|
|
3541
|
-
|
|
3664
|
+
const attrs = (deviceNode && deviceNode.attrs) || {};
|
|
3665
|
+
const device = deviceIdOf(attrs.jid);
|
|
3666
|
+
if (device === null) return null;
|
|
3542
3667
|
changes.push({ tag, deviceId: device });
|
|
3668
|
+
|
|
3669
|
+
// LID half — optional, and giving up on it costs the number half nothing.
|
|
3670
|
+
if (!lidOk) continue;
|
|
3671
|
+
const childLidHash = child.attrs.device_lid_hash;
|
|
3672
|
+
const lidDevice = deviceIdOf(attrs.lid);
|
|
3673
|
+
if (!childLidHash || lidDevice === null) { lidOk = false; continue; }
|
|
3674
|
+
if (lidHash === null) lidHash = String(childLidHash);
|
|
3675
|
+
else if (lidHash !== String(childLidHash)) { lidOk = false; continue; }
|
|
3676
|
+
lidChanges.push({ tag, deviceId: lidDevice });
|
|
3543
3677
|
}
|
|
3544
3678
|
|
|
3545
|
-
|
|
3679
|
+
if (!changes.length || !hash) return null;
|
|
3680
|
+
const haveLid = lidOk && lidHash && lidChanges.length === changes.length;
|
|
3681
|
+
return {
|
|
3682
|
+
changes,
|
|
3683
|
+
hash,
|
|
3684
|
+
lidChanges: haveLid ? lidChanges : null,
|
|
3685
|
+
lidHash: haveLid ? lidHash : null
|
|
3686
|
+
};
|
|
3546
3687
|
}
|
|
3547
3688
|
|
|
3548
3689
|
_processDeviceUpdate(devicesNode) {
|
|
@@ -4639,6 +4780,97 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4639
4780
|
return this._sendIq(node);
|
|
4640
4781
|
}
|
|
4641
4782
|
|
|
4783
|
+
/**
|
|
4784
|
+
* Put a trusted-contact token on file *before* the first message to a contact
|
|
4785
|
+
* is built, so that message carries a `<tctoken>` like every later one.
|
|
4786
|
+
*
|
|
4787
|
+
* The token never came from the contact. `_issuePrivacyTokens` is an IQ to
|
|
4788
|
+
* s.whatsapp.net and the server answers with the bytes, so nothing here waits
|
|
4789
|
+
* on the other side replying, having us in their address book, or a
|
|
4790
|
+
* conversation existing at all.
|
|
4791
|
+
*
|
|
4792
|
+
* What was missing was only the ordering. Issuance ran after
|
|
4793
|
+
* `_dispatchAndAck` and fire-and-forget, so the first message to any new
|
|
4794
|
+
* contact went out with no token and the server counted it as an anonymous
|
|
4795
|
+
* reach-out. At bulk that is one such event per number, which is exactly the
|
|
4796
|
+
* fuel for error 463. The official app issues on opening the chat — before
|
|
4797
|
+
* anything is typed — so `issue → send` is the faithful order, not `send →
|
|
4798
|
+
* issue`.
|
|
4799
|
+
*
|
|
4800
|
+
* Three cases return without waiting, and each matters:
|
|
4801
|
+
*
|
|
4802
|
+
* - a live token is already on file. Renewal is due eventually, but the
|
|
4803
|
+
* post-send path does that without holding a message up.
|
|
4804
|
+
* - `shouldIssue` is false. We already asked inside this 7-day bucket and
|
|
4805
|
+
* the server gave us nothing; asking again now would put a round-trip in
|
|
4806
|
+
* front of every send for nothing.
|
|
4807
|
+
* - the JID is the PSA account or a bot, which cannot timelock us.
|
|
4808
|
+
*
|
|
4809
|
+
* @param {string} tcJid storage key, already through _resolveTcTokenJid
|
|
4810
|
+
* @param {string} routingToJid the JID the message is addressed to
|
|
4811
|
+
* @returns {Promise<boolean>} whether a usable token is on file now
|
|
4812
|
+
*/
|
|
4813
|
+
async ensureTcTokenBeforeSend(tcJid, routingToJid) {
|
|
4814
|
+
const store = this._tcTokenStore;
|
|
4815
|
+
if (!store) return false;
|
|
4816
|
+
|
|
4817
|
+
const { tcTokenExpired, isRegularTcTokenUser } = require('./messages/TcTokenStore');
|
|
4818
|
+
if (!isRegularTcTokenUser(tcJid)) return false;
|
|
4819
|
+
|
|
4820
|
+
// Never ourselves. A note-to-self is not a reach-out, and holding it up for
|
|
4821
|
+
// a token nobody will check is the one stall this change could introduce.
|
|
4822
|
+
const bare = String(tcJid).split('@')[0].split(':')[0];
|
|
4823
|
+
const ownPhone = this._store && this._store.phoneNumber
|
|
4824
|
+
? String(this._store.phoneNumber) : null;
|
|
4825
|
+
const ownLidUsr = this._myLid ? String(this._myLid).split('@')[0].split(':')[0] : null;
|
|
4826
|
+
if ((ownPhone && bare === ownPhone) || (ownLidUsr && bare === ownLidUsr)) return false;
|
|
4827
|
+
|
|
4828
|
+
const live = entry =>
|
|
4829
|
+
!!(entry && entry.token && entry.token.length && !tcTokenExpired(entry.timestamp));
|
|
4830
|
+
|
|
4831
|
+
if (live(store.get(tcJid))) return true;
|
|
4832
|
+
if (!store.shouldIssue(tcJid)) return false;
|
|
4833
|
+
|
|
4834
|
+
// A second send to the same contact must wait on the first one's answer
|
|
4835
|
+
// rather than issue a competing IQ for the same JID.
|
|
4836
|
+
let pending = this._tcTokenIssuanceWaiters.get(tcJid);
|
|
4837
|
+
if (!pending) {
|
|
4838
|
+
const issueTs = Math.floor(Date.now() / 1000);
|
|
4839
|
+
const issueJid = this._resolveTcTokenIssuanceJid(routingToJid);
|
|
4840
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND issuing for ' + tcJid + ' (as ' + issueJid + ')');
|
|
4841
|
+
pending = this._issuePrivacyTokens(issueJid, issueTs)
|
|
4842
|
+
.then(result => { this._storeTcTokenFromIqResult(result, tcJid, issueTs); })
|
|
4843
|
+
.catch(err => {
|
|
4844
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND_ERR jid=' + tcJid + ' err=' + (err && err.message));
|
|
4845
|
+
})
|
|
4846
|
+
.finally(() => { this._tcTokenIssuanceWaiters.delete(tcJid); });
|
|
4847
|
+
this._tcTokenIssuanceWaiters.set(tcJid, pending);
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// Bounded wait. A send is never failed or delayed indefinitely over a token:
|
|
4851
|
+
// past the deadline it goes out without one, the issuance carries on in the
|
|
4852
|
+
// background, and the next message to this contact picks the token up.
|
|
4853
|
+
const limit = this._tcTokenPresendTimeoutMs;
|
|
4854
|
+
if (limit <= 0) return false;
|
|
4855
|
+
|
|
4856
|
+
let timer = null;
|
|
4857
|
+
const deadline = new Promise(resolve => {
|
|
4858
|
+
timer = setTimeout(() => resolve(false), limit);
|
|
4859
|
+
});
|
|
4860
|
+
const finished = await Promise.race([pending.then(() => true), deadline]);
|
|
4861
|
+
if (timer) clearTimeout(timer);
|
|
4862
|
+
|
|
4863
|
+
if (!finished) {
|
|
4864
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND timed out after ' + limit + 'ms jid=' + tcJid +
|
|
4865
|
+
' — sending without a token');
|
|
4866
|
+
return false;
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
const ok = live(store.get(tcJid));
|
|
4870
|
+
_whaDbg('[DBG] TCTOKEN_PRESEND ' + (ok ? 'ready' : 'no bytes returned') + ' jid=' + tcJid);
|
|
4871
|
+
return ok;
|
|
4872
|
+
}
|
|
4873
|
+
|
|
4642
4874
|
/**
|
|
4643
4875
|
* Parse the IQ result from `_issuePrivacyTokens` and persist the token.
|
|
4644
4876
|
*
|
|
@@ -5612,10 +5844,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5612
5844
|
|
|
5613
5845
|
// Change bio/about text
|
|
5614
5846
|
//
|
|
5615
|
-
// The <status> child is
|
|
5616
|
-
//
|
|
5617
|
-
//
|
|
5618
|
-
// uses types.ServerJID — which goes out as JID_PAIR + empty user + the server
|
|
5847
|
+
// The <status> child is the shape the server reads. The envelope was not:
|
|
5848
|
+
// this IQ has to be addressed to the server as a *JID* with nobody in the
|
|
5849
|
+
// user part, which goes out as JID_PAIR + empty user + the server
|
|
5619
5850
|
// token. This wrote the bare string 's.whatsapp.net', and a bare string is
|
|
5620
5851
|
// encoded as a plain dictionary token, so where every other client puts an
|
|
5621
5852
|
// address the server was reading text. That is the whole of the difference,
|
package/lib/DeviceManager.js
CHANGED
|
@@ -1201,19 +1201,28 @@ class DeviceManager {
|
|
|
1201
1201
|
* server's, nothing would ever match and every notification would take the
|
|
1202
1202
|
* fallback — which is where it started.
|
|
1203
1203
|
*
|
|
1204
|
-
*
|
|
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
|
|
1205
1211
|
* @param {Array<{tag: string, deviceId: number}>} changes
|
|
1206
|
-
* @param {string} serverHash
|
|
1212
|
+
* @param {string} serverHash device_hash, or device_lid_hash
|
|
1213
|
+
* @param {string} [server] 's.whatsapp.net' (default) or 'lid'
|
|
1207
1214
|
* @returns {boolean} whether the result verified and the cache was kept
|
|
1208
1215
|
*/
|
|
1209
|
-
verifyDeviceDelta(
|
|
1210
|
-
|
|
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;
|
|
1211
1220
|
|
|
1212
1221
|
// No baseline means there is nothing to apply a change to. Caching just the
|
|
1213
1222
|
// one device named in the notification would make a list of one look
|
|
1214
1223
|
// authoritative, which is how a recipient's other devices stop being
|
|
1215
1224
|
// written to.
|
|
1216
|
-
const cached = this._dcGet(
|
|
1225
|
+
const cached = this._dcGet(cacheKey);
|
|
1217
1226
|
if (!cached || !cached.length) return false;
|
|
1218
1227
|
|
|
1219
1228
|
const next = new Set(cached);
|
|
@@ -1225,7 +1234,7 @@ class DeviceManager {
|
|
|
1225
1234
|
}
|
|
1226
1235
|
|
|
1227
1236
|
const ids = [...next].sort((a, b) => a - b);
|
|
1228
|
-
const jids = ids.map(d => makeDeviceJid(
|
|
1237
|
+
const jids = ids.map(d => makeDeviceJid(user, d, server));
|
|
1229
1238
|
|
|
1230
1239
|
let ours;
|
|
1231
1240
|
try {
|
|
@@ -1238,14 +1247,14 @@ class DeviceManager {
|
|
|
1238
1247
|
}
|
|
1239
1248
|
|
|
1240
1249
|
if (ours !== String(serverHash)) {
|
|
1241
|
-
_whaDbg('[DBG] DEV_HASH mismatch for ' +
|
|
1250
|
+
_whaDbg('[DBG] DEV_HASH mismatch for ' + cacheKey + ' ours=' + ours +
|
|
1242
1251
|
' server=' + serverHash + ' — dropping cache');
|
|
1243
|
-
this._dcDel([
|
|
1252
|
+
this._dcDel([cacheKey]);
|
|
1244
1253
|
return false;
|
|
1245
1254
|
}
|
|
1246
1255
|
|
|
1247
|
-
this._dcSet(
|
|
1248
|
-
_whaDbg('[DBG] DEV_HASH verified for ' +
|
|
1256
|
+
this._dcSet(cacheKey, ids);
|
|
1257
|
+
_whaDbg('[DBG] DEV_HASH verified for ' + cacheKey + ' ids=[' + ids.join(',') +
|
|
1249
1258
|
'] hash=' + ours);
|
|
1250
1259
|
return true;
|
|
1251
1260
|
}
|
package/lib/PlayStore.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Fetching the WhatsApp APK from Google Play, the
|
|
4
|
-
//
|
|
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
|
|
254
|
+
} catch (_) { /* best effort */ }
|
|
256
255
|
}
|
|
257
256
|
|
|
258
257
|
// { baseUrl, splits: [{name, url}], cookies: {name: value} }
|
package/lib/Registration.js
CHANGED
|
@@ -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 —
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
750
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
1113
|
-
//
|
|
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
|
|
1297
|
-
//
|
|
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 };
|
|
@@ -1097,6 +1097,28 @@ class MessageSender {
|
|
|
1097
1097
|
// filed when it arrived. Looking it up by the phone JID — which is what
|
|
1098
1098
|
// this did — never found one, so nothing was ever attached.
|
|
1099
1099
|
const tcJid = this._client._resolveTcTokenJid(routingToJid);
|
|
1100
|
+
|
|
1101
|
+
// A protocol message — a revoke, an ephemeral-timer change — is bookkeeping
|
|
1102
|
+
// rather than a reach-out, so it never triggers an issuance. An existing
|
|
1103
|
+
// token still rides along on it, which is what the apps do.
|
|
1104
|
+
const isProtocolMsg = !!(options && options._protocol);
|
|
1105
|
+
|
|
1106
|
+
// First message to this contact: fetch the token now, before the stanza is
|
|
1107
|
+
// built, so it goes out on this message instead of on the next one.
|
|
1108
|
+
//
|
|
1109
|
+
// The device enumeration above has already run, so the LID is known by the
|
|
1110
|
+
// time we get here and the issuance and the storage agree on the contact.
|
|
1111
|
+
// Doing this any earlier would issue against the phone JID while the token
|
|
1112
|
+
// got filed under the LID, which is the split that used to leave live
|
|
1113
|
+
// tokens unused.
|
|
1114
|
+
//
|
|
1115
|
+
// Returns without waiting when a live token is already on file, so this
|
|
1116
|
+
// costs one round-trip per contact rather than one per message. It never
|
|
1117
|
+
// throws and never fails the send.
|
|
1118
|
+
if (tcStore && !isProtocolMsg) {
|
|
1119
|
+
await this._client.ensureTcTokenBeforeSend(tcJid, routingToJid);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1100
1122
|
if (tcStore) {
|
|
1101
1123
|
const tcEntry = tcStore.get(tcJid);
|
|
1102
1124
|
if (tcEntry && tcEntry.token && tcEntry.token.length) {
|
|
@@ -1153,11 +1175,16 @@ class MessageSender {
|
|
|
1153
1175
|
}
|
|
1154
1176
|
}
|
|
1155
1177
|
|
|
1156
|
-
// ─── Fire-and-forget:
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1178
|
+
// ─── Fire-and-forget: renew the privacy token ────────────────────────────
|
|
1179
|
+
// The first message to a contact now gets its token before the stanza is
|
|
1180
|
+
// built, up above, so what is left here is renewal: a contact whose token
|
|
1181
|
+
// is still live but whose 7-day bucket has rolled over. That case must not
|
|
1182
|
+
// hold a message up — the token being attached right now is perfectly
|
|
1183
|
+
// valid — so it stays fire-and-forget.
|
|
1184
|
+
//
|
|
1185
|
+
// The gate below is what keeps the two from firing twice for one contact.
|
|
1186
|
+
// A successful pre-send issuance saves senderTimestamp, which puts
|
|
1187
|
+
// shouldIssue in the current bucket and makes this a no-op.
|
|
1161
1188
|
//
|
|
1162
1189
|
// Two targets never get one. WhatsApp's own announcement account
|
|
1163
1190
|
// (0@s.whatsapp.net) and the bot numbers, including Meta AI, are not
|
|
@@ -1166,7 +1193,6 @@ class MessageSender {
|
|
|
1166
1193
|
// too — they are bookkeeping, not a reach-out. Attaching an existing token
|
|
1167
1194
|
// to them is still correct
|
|
1168
1195
|
// and still happens above — this only governs asking for a new one.
|
|
1169
|
-
const isProtocolMsg = !!(options && options._protocol);
|
|
1170
1196
|
if (tcStore && isRegularTcTokenUser(tcJid) && !isProtocolMsg &&
|
|
1171
1197
|
tcStore.shouldIssue(tcJid) &&
|
|
1172
1198
|
!this._client._inFlightTcTokenIssuance.has(tcJid)) {
|
|
@@ -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
|
|
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
|
|
64
|
-
//
|
|
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
|
|
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
|
//
|