whalibmob 5.9.0 → 5.9.2
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 +1 -0
- package/cli.js +7 -0
- package/lib/Client.js +105 -104
- package/lib/DeviceManager.js +25 -26
- package/lib/MediaService.js +2 -2
- package/lib/MediaThumbnail.js +5 -7
- package/lib/auth-utils.js +11 -13
- package/lib/messages/MessageSender.js +60 -72
- package/lib/messages/ReportingToken.js +4 -6
- package/lib/messages/TcTokenStore.js +9 -15
- package/lib/noise.js +3 -4
- package/lib/proto/MessageProto.js +24 -14
- package/lib/signal/SignalProtocol.js +4 -4
- package/lib/signal/SignalStore.js +2 -2
- package/package.json +1 -1
package/lib/DeviceManager.js
CHANGED
|
@@ -45,8 +45,8 @@ function jidStrToObj(jidStr) {
|
|
|
45
45
|
return { user, agent: 0, device, server, toString() { return self; } };
|
|
46
46
|
}
|
|
47
47
|
// @lid multi-device (e.g. "112713111982325:2@lid"): AD_JID with the domain in
|
|
48
|
-
// the agent byte — 1 for lid
|
|
49
|
-
//
|
|
48
|
+
// the agent byte — 1 for lid. AD_JID with a domain type is the shape whenever
|
|
49
|
+
// a device is present.
|
|
50
50
|
//
|
|
51
51
|
// This previously packed the device into the user string as a JID_PAIR
|
|
52
52
|
// ("112713111982325:2"), a workaround from when the encoder could not express
|
|
@@ -204,7 +204,7 @@ class DeviceManager {
|
|
|
204
204
|
// It loads the on-disk device cache so the very first send after a process
|
|
205
205
|
// restart is instant — no cold-start usync round-trip needed.
|
|
206
206
|
//
|
|
207
|
-
// Files saved in session dir
|
|
207
|
+
// Files saved in session dir:
|
|
208
208
|
// <phone>.device-cache.json ← combined cache (internal)
|
|
209
209
|
// device-list-447911234567@s.whatsapp.net.json ← per-phone device IDs
|
|
210
210
|
// device-list-139471160877194@lid.json ← per-LID device IDs
|
|
@@ -268,7 +268,7 @@ class DeviceManager {
|
|
|
268
268
|
'utf8'
|
|
269
269
|
);
|
|
270
270
|
|
|
271
|
-
// ── Individual device-list files
|
|
271
|
+
// ── Individual device-list files ───────────────────────────────────────
|
|
272
272
|
for (const [key, ids] of Object.entries(entries)) {
|
|
273
273
|
let fileName;
|
|
274
274
|
if (key.startsWith('lid:')) {
|
|
@@ -411,13 +411,12 @@ class DeviceManager {
|
|
|
411
411
|
return jids.length > 0 ? jids : phones.map(p => makeDeviceJid(p, 0));
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
// ─── USync executor (
|
|
414
|
+
// ─── USync executor (mobile-API transport) ──────────────────────────────────
|
|
415
415
|
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// the web protocol — so three adaptations are applied:
|
|
416
|
+
// Builds the <query> and <list> nodes from the USyncQuery protocols/users,
|
|
417
|
+
// sends the IQ, and parses the response through
|
|
418
|
+
// `usyncQuery.parseUSyncQueryResult`. Talking the WhatsApp *mobile* protocol
|
|
419
|
+
// rather than the web one calls for three adaptations:
|
|
421
420
|
//
|
|
422
421
|
// 1. Nodes use whalibmob's BinaryNode (`description` field) instead of the
|
|
423
422
|
// web `{ tag }` object literals.
|
|
@@ -431,7 +430,7 @@ class DeviceManager {
|
|
|
431
430
|
// protocol → <user><contact>+phone</contact></user>. The mobile server
|
|
432
431
|
// ignores the <user jid="..."> form for phone-based device discovery.
|
|
433
432
|
// • Discovery for an already-known routing JID (a LID) uses
|
|
434
|
-
// USyncUser().withId(jid) → <user jid="..."
|
|
433
|
+
// USyncUser().withId(jid) → <user jid="...">.
|
|
435
434
|
async executeUSyncQuery(usyncQuery) {
|
|
436
435
|
if (!usyncQuery || usyncQuery.protocols.length === 0) {
|
|
437
436
|
throw new Error('USyncQuery must have at least one protocol');
|
|
@@ -464,9 +463,9 @@ class DeviceManager {
|
|
|
464
463
|
return { user: (server === 'lid' && device > 0) ? raw : user, server, toString() { return str; } };
|
|
465
464
|
};
|
|
466
465
|
|
|
467
|
-
// <user> nodes — jid attr is set only when the user has no phone
|
|
468
|
-
//
|
|
469
|
-
//
|
|
466
|
+
// <user> nodes — the jid attr is set only when the user has no phone; phone
|
|
467
|
+
// users carry a <contact> child instead, produced by the contact protocol's
|
|
468
|
+
// getUserElement.
|
|
470
469
|
const userNodes = usyncQuery.users.map(user => {
|
|
471
470
|
const children = usyncQuery.protocols
|
|
472
471
|
.map(p => p.getUserElement(user))
|
|
@@ -535,9 +534,9 @@ class DeviceManager {
|
|
|
535
534
|
// Fix C — context and side_list must match _doContactUsync (the working format):
|
|
536
535
|
// context="interactive" + <side_list/> is REQUIRED. context="message" → server ignores IQ.
|
|
537
536
|
async _doUsyncIq(phones) {
|
|
538
|
-
// Build the device-discovery query
|
|
539
|
-
//
|
|
540
|
-
//
|
|
537
|
+
// Build the device-discovery query via USyncQuery and the device / LID /
|
|
538
|
+
// contact protocols, then run it through the mobile-API executor and parse
|
|
539
|
+
// it with the shared parseUSyncQueryResult.
|
|
541
540
|
//
|
|
542
541
|
// Users are addressed by PHONE (USyncUser().withPhone → <contact>+phone</>)
|
|
543
542
|
// because the WA *mobile* server silently drops usync IQs that address
|
|
@@ -626,9 +625,9 @@ class DeviceManager {
|
|
|
626
625
|
for (const dev of deviceList) {
|
|
627
626
|
const devId = Number.isFinite(dev.id) ? dev.id : 0;
|
|
628
627
|
const keyIdx = Number.isFinite(dev.keyIndex) ? dev.keyIndex : -1;
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
628
|
+
// Keep device 0, and non-zero devices that carry a valid
|
|
629
|
+
// key-index — otherwise the encrypt IQ returns a bad-request or an
|
|
630
|
+
// empty bundle for that device.
|
|
632
631
|
if (devId === 0 || keyIdx > 0) {
|
|
633
632
|
this._dcAdd(cachePhone, devId);
|
|
634
633
|
}
|
|
@@ -883,8 +882,8 @@ class DeviceManager {
|
|
|
883
882
|
// expressed as <ourLid>:<device>@lid.
|
|
884
883
|
//
|
|
885
884
|
// Device 0 is excluded exactly as it is on the phone path: it is the device
|
|
886
|
-
// doing the sending, and
|
|
887
|
-
//
|
|
885
|
+
// doing the sending, and the sender device is never one of its own
|
|
886
|
+
// participants.
|
|
888
887
|
async ensureOwnLidDeviceSessions(lidUser, ownPhone, signalProto, allowPkmsg = true) {
|
|
889
888
|
if (!lidUser) return [];
|
|
890
889
|
const cacheKey = 'lid:' + lidUser;
|
|
@@ -977,7 +976,7 @@ class DeviceManager {
|
|
|
977
976
|
async _doContactUsync(phones) {
|
|
978
977
|
// Contact-only fallback (device/lid protocols omitted): the plain contact
|
|
979
978
|
// query is the form the mobile server reliably answers when the richer
|
|
980
|
-
// device query times out. Built via the shared
|
|
979
|
+
// device query times out. Built via the shared USyncQuery builder.
|
|
981
980
|
const query = new USyncQuery()
|
|
982
981
|
.withContext('interactive')
|
|
983
982
|
.withContactProtocol();
|
|
@@ -1031,9 +1030,9 @@ class DeviceManager {
|
|
|
1031
1030
|
const jidStr = typeof jid === 'string' ? jid : String(jid);
|
|
1032
1031
|
|
|
1033
1032
|
// Discovery for an already-known routing JID (a LID) — addressed via
|
|
1034
|
-
// USyncUser().withId(jid) → <user jid="..."
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1033
|
+
// USyncUser().withId(jid) → <user jid="...">. executeUSyncQuery encodes
|
|
1034
|
+
// the jid attr as a JID object so the mobile binary encoder emits proper
|
|
1035
|
+
// JID_PAIR bytes.
|
|
1037
1036
|
const query = new USyncQuery()
|
|
1038
1037
|
.withContext('interactive')
|
|
1039
1038
|
.withDeviceProtocol()
|
package/lib/MediaService.js
CHANGED
|
@@ -119,8 +119,8 @@ function decryptMedia(encryptedWithMac, mediaKey, keyName) {
|
|
|
119
119
|
|
|
120
120
|
// Upload encrypted media, trying every CDN host in turn.
|
|
121
121
|
//
|
|
122
|
-
// refreshAuth (optional)
|
|
123
|
-
//
|
|
122
|
+
// refreshAuth (optional): when a host answers but the upload yields no usable
|
|
123
|
+
// URL — the signature of an expired or
|
|
124
124
|
// rejected auth token — the auth is force-refreshed once and the hosts are
|
|
125
125
|
// retried with the fresh credentials. Without this a token that expires between
|
|
126
126
|
// the TTL check and the upload burns through every host and fails permanently
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
// drawn, so the recipient gets the grey placeholder with a download arrow and
|
|
9
9
|
// has to tap it before seeing anything.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the same, with the difference that none of it is mandatory:
|
|
11
|
+
// The preview is the source resized to 32 pixels wide at JPEG quality 50, with
|
|
12
|
+
// the original dimensions read from the same decode. None of it is mandatory:
|
|
14
13
|
//
|
|
15
14
|
// • Dimensions are read straight out of the file header — JPEG, PNG, WebP,
|
|
16
15
|
// GIF and BMP — with no library at all.
|
|
@@ -31,8 +30,8 @@ const path = require('path');
|
|
|
31
30
|
const crypto = require('crypto');
|
|
32
31
|
const { execFile } = require('child_process');
|
|
33
32
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
33
|
+
// The preview is deliberately tiny; it is only ever shown blurred behind the
|
|
34
|
+
// real image while that downloads.
|
|
36
35
|
const THUMB_WIDTH = 32;
|
|
37
36
|
const THUMB_QUALITY = 50;
|
|
38
37
|
|
|
@@ -358,8 +357,7 @@ async function videoThumbnail(input, opts) {
|
|
|
358
357
|
} catch (_) {}
|
|
359
358
|
}
|
|
360
359
|
|
|
361
|
-
//
|
|
362
|
-
// with the aspect ratio kept.
|
|
360
|
+
// First frame, scaled to the preview width with the aspect ratio kept.
|
|
363
361
|
const dest = tmpFile('.jpg');
|
|
364
362
|
try {
|
|
365
363
|
const ok = await run('ffmpeg', [
|
package/lib/auth-utils.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// ─── auth-utils.js ────────────────────────────────────────────────────────────
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// Four utilities built around whalibmob's architecture (method-based
|
|
6
|
+
// SignalStore, Buffer key pairs, etc.):
|
|
7
7
|
//
|
|
8
8
|
// 1. makeCacheableSignalKeyStore — wraps SignalStore with NodeCache (5-min TTL)
|
|
9
9
|
// and a Mutex for all read/write operations
|
|
@@ -591,7 +591,6 @@ function addTransactionCapability(state, logger, opts) {
|
|
|
591
591
|
* Returns the authenticated user's JID (phone@s.whatsapp.net) or throws
|
|
592
592
|
* a descriptive error if the store is not yet fully authenticated.
|
|
593
593
|
*
|
|
594
|
-
* Equivalent to Baileys' assertMeId(creds) but for whalibmob's store format.
|
|
595
594
|
* Use this anywhere we'd otherwise reach for `store.phoneNumber` directly,
|
|
596
595
|
* to fail fast with a clear error instead of a silent null-reference crash.
|
|
597
596
|
*
|
|
@@ -623,12 +622,12 @@ function assertMeId(store) {
|
|
|
623
622
|
* Creates a fresh set of whalibmob auth credentials (key pairs, registration ID,
|
|
624
623
|
* device identifiers) for a given phone number.
|
|
625
624
|
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
625
|
+
* Returns whalibmob's store format (Buffer key pairs, signedPreKey as
|
|
626
|
+
* { id, public, private, signature }).
|
|
628
627
|
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
* code that
|
|
628
|
+
* A few extra fields (nextPreKeyId, firstUnuploadedPreKeyId, accountSyncCounter,
|
|
629
|
+
* accountSettings, advSecretKey, etc.) are appended for account sync and for
|
|
630
|
+
* application code that expects them.
|
|
632
631
|
*
|
|
633
632
|
* @param {string|number} phoneNumber — full international number (e.g. "40712345678")
|
|
634
633
|
* @param {object} options — optional overrides:
|
|
@@ -643,9 +642,8 @@ function initAuthCreds(phoneNumber, options) {
|
|
|
643
642
|
// Use whalibmob's own key-generation machinery (curve25519-js, proper prefixes)
|
|
644
643
|
const store = createNewStore(phoneNumber);
|
|
645
644
|
|
|
646
|
-
// ── Extra
|
|
647
|
-
//
|
|
648
|
-
// application code that was originally written against Baileys.
|
|
645
|
+
// ── Extra credential fields ──────────────────────────────────────────────────
|
|
646
|
+
// Not required internally, but commonly expected by application code.
|
|
649
647
|
|
|
650
648
|
/** Next pre-key ID to generate (monotonically increasing). */
|
|
651
649
|
store.nextPreKeyId = 1;
|
|
@@ -656,7 +654,7 @@ function initAuthCreds(phoneNumber, options) {
|
|
|
656
654
|
/** Sync counter used in account-sync IQ stanzas. */
|
|
657
655
|
store.accountSyncCounter = 0;
|
|
658
656
|
|
|
659
|
-
/** Account-level settings
|
|
657
|
+
/** Account-level settings. */
|
|
660
658
|
store.accountSettings = { unarchiveChats: false };
|
|
661
659
|
|
|
662
660
|
/** Processed history messages — used to avoid duplicate handling on reconnect. */
|
|
@@ -664,7 +662,7 @@ function initAuthCreds(phoneNumber, options) {
|
|
|
664
662
|
|
|
665
663
|
/**
|
|
666
664
|
* 32-byte random secret used for ADV (multi-device) poll encryption.
|
|
667
|
-
* Stored as a base64 string
|
|
665
|
+
* Stored as a base64 string.
|
|
668
666
|
*/
|
|
669
667
|
store.advSecretKey = crypto.randomBytes(32).toString('base64');
|
|
670
668
|
|
|
@@ -43,8 +43,7 @@ function isGroupJid(jid) {
|
|
|
43
43
|
|
|
44
44
|
// A Status/Story is posted as a message to status@broadcast, and it travels the
|
|
45
45
|
// SenderKey path a group message does — one skmsg plus a distribution to every
|
|
46
|
-
// recipient — not the 1:1 path.
|
|
47
|
-
// whatsmeow routes BroadcastServer into sendGroup for the same reason.
|
|
46
|
+
// recipient — not the 1:1 path. A Status and a group are the same case here.
|
|
48
47
|
const STATUS_BROADCAST_JID = 'status@broadcast';
|
|
49
48
|
|
|
50
49
|
function isStatusBroadcast(jid) {
|
|
@@ -196,18 +195,14 @@ function adString(jid) {
|
|
|
196
195
|
return `${user}.0:${device}@${server}`;
|
|
197
196
|
}
|
|
198
197
|
|
|
199
|
-
// Participant-list hash v2,
|
|
200
|
-
//
|
|
201
|
-
// BYTES of the digest — eight characters, prefixed "2:".
|
|
198
|
+
// Participant-list hash v2: sort the AD strings, sha256 the concatenation, then
|
|
199
|
+
// base64 the FIRST SIX BYTES of the digest — eight characters, prefixed "2:".
|
|
202
200
|
//
|
|
203
|
-
// This previously
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// which is why the discrepancy has gone unnoticed there. whalibmob did put it
|
|
209
|
-
// on the wire, so every multi-device send carried a hash the server could not
|
|
210
|
-
// match against its own view of the participant list.
|
|
201
|
+
// This previously hashed plain JID strings and took the first six CHARACTERS of
|
|
202
|
+
// the base64 of the whole digest, which is a different, six-character value over
|
|
203
|
+
// different input. It goes on the wire, so every multi-device send carried a
|
|
204
|
+
// hash the server could not match against its own view of the participant
|
|
205
|
+
// list.
|
|
211
206
|
function computePhash(jids) {
|
|
212
207
|
const sorted = [...new Set(jids.map(adString))].sort();
|
|
213
208
|
const hash = crypto.createHash('sha256').update(sorted.join('')).digest();
|
|
@@ -341,7 +336,7 @@ class MessageSender {
|
|
|
341
336
|
gifPlayback: options.gifPlayback || false,
|
|
342
337
|
contextInfo: options.contextInfo
|
|
343
338
|
}));
|
|
344
|
-
//
|
|
339
|
+
// A video with gifPlayback is labelled mediatype "gif", not "video".
|
|
345
340
|
return this._sendMessage(toJid, msgId, vidBuf, 'media',
|
|
346
341
|
{ ...options, _mediaSubtype: options.gifPlayback ? 'gif' : 'video' });
|
|
347
342
|
}
|
|
@@ -459,8 +454,8 @@ class MessageSender {
|
|
|
459
454
|
selectableOptionsCount: selectableCount != null ? selectableCount : 0,
|
|
460
455
|
messageSecret: opts.messageSecret || opts.encKey || null
|
|
461
456
|
});
|
|
462
|
-
//
|
|
463
|
-
//
|
|
457
|
+
// The poll has to be paired with a messageContextInfo holding the secret.
|
|
458
|
+
// It is a sibling field of the poll inside Message, not
|
|
464
459
|
// part of the poll — a voter derives their vote key from this copy, so a
|
|
465
460
|
// poll sent without it cannot be voted on.
|
|
466
461
|
const pollBuf = Buffer.concat([
|
|
@@ -497,9 +492,9 @@ class MessageSender {
|
|
|
497
492
|
contextInfo: opts.contextInfo
|
|
498
493
|
}));
|
|
499
494
|
// A location is a plain message as far as the envelope is concerned.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
495
|
+
// The stanza types are text, media, reaction and poll — LocationMessage is
|
|
496
|
+
// in none of the media cases, so it is text. Sending type="location" hands
|
|
497
|
+
// the server a type it has no case for.
|
|
503
498
|
return this._sendMessage(toJid, msgId, locBuf, 'text', opts);
|
|
504
499
|
}
|
|
505
500
|
|
|
@@ -922,9 +917,8 @@ class MessageSender {
|
|
|
922
917
|
const allowPkmsg = true;
|
|
923
918
|
|
|
924
919
|
// ── Addressing family ─────────────────────────────────────────────────────
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
// never converts between the two:
|
|
920
|
+
// The whole address family comes from the JID the caller passed, and the
|
|
921
|
+
// two are never converted between:
|
|
928
922
|
//
|
|
929
923
|
// const targetUserServer = isLid ? 'lid' : 's.whatsapp.net'
|
|
930
924
|
// const senderIdentity = isLid && meLid ? <ourLid> : <ourPn>
|
|
@@ -939,9 +933,9 @@ class MessageSender {
|
|
|
939
933
|
// The recipient's devices followed the caller's JID but ours were always
|
|
940
934
|
// enumerated by phone, so a DM to an @lid went out with @lid recipient
|
|
941
935
|
// entries and @s.whatsapp.net entries for our own linked devices in the same
|
|
942
|
-
// <participants> list.
|
|
943
|
-
//
|
|
944
|
-
//
|
|
936
|
+
// <participants> list. The pair has to be [to, ownID] with ownID switched to
|
|
937
|
+
// our LID for @lid destinations; a participant list the server cannot
|
|
938
|
+
// reconcile is what comes back as 479.
|
|
945
939
|
const isLidTarget = toJid.endsWith('@lid');
|
|
946
940
|
const routingToJid = toJid;
|
|
947
941
|
const ownLidUser = this._client._myLid ? phoneFromJid(this._client._myLid) : null;
|
|
@@ -955,8 +949,7 @@ class MessageSender {
|
|
|
955
949
|
}
|
|
956
950
|
|
|
957
951
|
// Enumerate the recipient's devices and our own in parallel, both in the
|
|
958
|
-
// address family the destination JID dictates
|
|
959
|
-
// getUSyncDevices([senderIdentity, jid]) call.
|
|
952
|
+
// address family the destination JID dictates.
|
|
960
953
|
let otherJids, ownLinkedJids, ownPrimaryJid;
|
|
961
954
|
if (isLidTarget) {
|
|
962
955
|
const lidUser = phoneFromJid(toJid);
|
|
@@ -981,8 +974,7 @@ class MessageSender {
|
|
|
981
974
|
ownPrimaryJid = this._client._ownDeviceJid();
|
|
982
975
|
}
|
|
983
976
|
|
|
984
|
-
// The device doing the sending is never one of its own recipients.
|
|
985
|
-
// Baileys ("Skipping exact sender device") and whatsmeow drop it explicitly.
|
|
977
|
+
// The device doing the sending is never one of its own recipients.
|
|
986
978
|
const ownPrimaryAddr = adString(ownPrimaryJid);
|
|
987
979
|
otherJids = otherJids.filter(j => adString(j) !== ownPrimaryAddr);
|
|
988
980
|
ownLinkedJids = ownLinkedJids.filter(j => adString(j) !== ownPrimaryAddr);
|
|
@@ -999,12 +991,12 @@ class MessageSender {
|
|
|
999
991
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1000
992
|
|
|
1001
993
|
// The hash covers every device the server enumerates for this pair of users,
|
|
1002
|
-
// our own primary included —
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
994
|
+
// our own primary included — the sending device is skipped only when it
|
|
995
|
+
// comes to encrypting. Excluding it here produced a hash over a different
|
|
996
|
+
// set than the server's.
|
|
1005
997
|
//
|
|
1006
|
-
// It is not put on the stanza:
|
|
1007
|
-
//
|
|
998
|
+
// It is not put on the stanza: phash belongs on a group message, not a 1:1
|
|
999
|
+
// one. It is carried inside the (encrypted)
|
|
1008
1000
|
// DeviceSentMessage for our own devices, and compared against the phash the
|
|
1009
1001
|
// server echoes back on the ack so a drifted device cache gets flushed.
|
|
1010
1002
|
const phash = computePhash([...otherJids, ownPrimaryJid, ...ownLinkedJids]);
|
|
@@ -1115,8 +1107,7 @@ class MessageSender {
|
|
|
1115
1107
|
|
|
1116
1108
|
// The server echoes its own participant hash on the ack. A difference means
|
|
1117
1109
|
// its device list for this chat is not the one we just fanned out to, so the
|
|
1118
|
-
// cached list is stale — drop it and let the next send re-run usync
|
|
1119
|
-
// as whatsmeow does when the hashes disagree.
|
|
1110
|
+
// cached list is stale — drop it and let the next send re-run usync.
|
|
1120
1111
|
if (dispatchResult && dispatchResult.phash && dispatchResult.phash !== phash) {
|
|
1121
1112
|
_whaDbg('[DBG] PHASH_MISMATCH ours=' + phash + ' server=' + dispatchResult.phash +
|
|
1122
1113
|
' to=' + toJid + ' — flushing device cache');
|
|
@@ -1136,10 +1127,9 @@ class MessageSender {
|
|
|
1136
1127
|
// Two targets never get one. WhatsApp's own announcement account
|
|
1137
1128
|
// (0@s.whatsapp.net) and the bot numbers, including Meta AI, are not
|
|
1138
1129
|
// contacts you can be "timelocked" against, and no real client issues to
|
|
1139
|
-
// them
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
// not a reach-out. Attaching an existing token to them is still correct
|
|
1130
|
+
// them. Protocol messages (a revoke, an ephemeral-timer change) are out
|
|
1131
|
+
// too — they are bookkeeping, not a reach-out. Attaching an existing token
|
|
1132
|
+
// to them is still correct
|
|
1143
1133
|
// and still happens above — this only governs asking for a new one.
|
|
1144
1134
|
const isProtocolMsg = !!(options && options._protocol);
|
|
1145
1135
|
if (tcStore && isRegularTcTokenUser(tcJid) && !isProtocolMsg &&
|
|
@@ -1219,9 +1209,9 @@ class MessageSender {
|
|
|
1219
1209
|
}
|
|
1220
1210
|
|
|
1221
1211
|
// Determine addressing mode for this group (lid = modern groups, pn = legacy).
|
|
1222
|
-
// A Status carries none:
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1212
|
+
// A Status carries none: addressing_mode is set only for the group server,
|
|
1213
|
+
// and a Status goes to ordinary contacts, so everything below stays in
|
|
1214
|
+
// phone space.
|
|
1225
1215
|
const groupAddressingMode = isStatus
|
|
1226
1216
|
? null
|
|
1227
1217
|
: (this._client._groupAddressingMode.get(groupJid) || 'lid');
|
|
@@ -1258,9 +1248,8 @@ class MessageSender {
|
|
|
1258
1248
|
// out of the member walk and put back through the own-device path below.
|
|
1259
1249
|
// Left in, the walk enumerates our devices starting at device 0 — the very
|
|
1260
1250
|
// device doing the sending — and it lands in <participants> as a recipient
|
|
1261
|
-
// of our own SenderKey.
|
|
1262
|
-
//
|
|
1263
|
-
// `jid == ownJID || jid == ownLID`.
|
|
1251
|
+
// of our own SenderKey. The exact sender device has to be skipped, matched
|
|
1252
|
+
// against both our phone JID and our LID.
|
|
1264
1253
|
//
|
|
1265
1254
|
// The phone branch already dropped us via `p !== ownPhone`; the LID branch
|
|
1266
1255
|
// never did, so this only ever misfired on LID-addressed groups.
|
|
@@ -1313,10 +1302,10 @@ class MessageSender {
|
|
|
1313
1302
|
]);
|
|
1314
1303
|
|
|
1315
1304
|
// Every device this group resolves to, our own primary included, deduped so
|
|
1316
|
-
// a device reached through more than one path is counted once.
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1305
|
+
// a device reached through more than one path is counted once. The hash
|
|
1306
|
+
// covers the whole enumeration, sending device and all, with hosted
|
|
1307
|
+
// endpoints dropped first since those are bot-side and never real
|
|
1308
|
+
// participants.
|
|
1320
1309
|
const isHostedJid = (jid) => {
|
|
1321
1310
|
const s = String(jid);
|
|
1322
1311
|
return s.endsWith('@hosted') || s.endsWith('@hosted.lid');
|
|
@@ -1334,14 +1323,14 @@ class MessageSender {
|
|
|
1334
1323
|
[...memberDevices, ...lidDevices, ...ownTargets, ownSelfJid]
|
|
1335
1324
|
)].filter(jid => !isHostedJid(jid));
|
|
1336
1325
|
|
|
1337
|
-
// The participant hash the server checks the fan-out against.
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1326
|
+
// The participant hash the server checks the fan-out against. It goes on
|
|
1327
|
+
// group stanzas and nowhere else, and is computed over the enumeration
|
|
1328
|
+
// above rather than over the encryption targets,
|
|
1340
1329
|
// so the sending device counts here even though it is not a recipient.
|
|
1341
1330
|
const phash = computePhash(enumeratedDevices);
|
|
1342
1331
|
|
|
1343
|
-
// Targets to actually encrypt for: everything but the sending device
|
|
1344
|
-
//
|
|
1332
|
+
// Targets to actually encrypt for: everything but the sending device, and
|
|
1333
|
+
// it is skipped at this point rather than earlier.
|
|
1345
1334
|
// Only the device actually sending. On a companion link that is not the
|
|
1346
1335
|
// account's phone — the phone is a peer that has to receive the SenderKey
|
|
1347
1336
|
// like everyone else, so excluding the bare number here would leave the
|
|
@@ -1364,8 +1353,8 @@ class MessageSender {
|
|
|
1364
1353
|
|
|
1365
1354
|
const skStore = this._signal.senderKeyStore;
|
|
1366
1355
|
const existingSkdmMap = skStore.getSKDMMap(groupJid);
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1356
|
+
// Exclusions when choosing SenderKey recipients: hosted (bot-side) users
|
|
1357
|
+
// and device 99, which is a server placeholder
|
|
1369
1358
|
// rather than a real endpoint, must never receive a distribution.
|
|
1370
1359
|
const isSkdmEligible = (jid) => {
|
|
1371
1360
|
const s = String(jid);
|
|
@@ -1408,10 +1397,10 @@ class MessageSender {
|
|
|
1408
1397
|
msgContent.push(new BinaryNode('device-identity', {}, advBytes));
|
|
1409
1398
|
}
|
|
1410
1399
|
if (skdmEncrypted.length > 0) {
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1400
|
+
// The same extra attrs — mediatype included — belong on the SenderKey
|
|
1401
|
+
// participant nodes as on the skmsg, so an image sent to a group carries
|
|
1402
|
+
// mediatype on both. Passing null here left the SKDM <enc> nodes
|
|
1403
|
+
// unlabelled.
|
|
1415
1404
|
msgContent.push(DeviceManager.buildParticipantsNode(skdmEncrypted, mediaSubtype));
|
|
1416
1405
|
}
|
|
1417
1406
|
const skmsgAttrs = { type: 'skmsg', v: '2' };
|
|
@@ -1429,10 +1418,9 @@ class MessageSender {
|
|
|
1429
1418
|
_whaDbg('[DBG] REPORTING_TOKEN attached to=' + groupJid + ' id=' + msgId);
|
|
1430
1419
|
}
|
|
1431
1420
|
|
|
1432
|
-
// phash belongs on a group stanza and only on a group stanza
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
// back on the ack when they differ.
|
|
1421
|
+
// phash belongs on a group stanza and only on a group stanza. It tells the
|
|
1422
|
+
// server which device set we fanned out to; the server compares it against
|
|
1423
|
+
// its own and echoes its hash back on the ack when they differ.
|
|
1436
1424
|
const stanzaAttrs = {
|
|
1437
1425
|
to: jidStrToObj(groupJid),
|
|
1438
1426
|
id: msgId,
|
|
@@ -1464,9 +1452,9 @@ class MessageSender {
|
|
|
1464
1452
|
|
|
1465
1453
|
// A hash on the ack that differs from ours means the server's participant
|
|
1466
1454
|
// list for this group is not the one we just fanned out to, so some members
|
|
1467
|
-
// did not get the message.
|
|
1468
|
-
// signal
|
|
1469
|
-
//
|
|
1455
|
+
// did not get the message. The group cache is dropped on exactly this
|
|
1456
|
+
// signal, and the device lists behind the members with it, so the next send
|
|
1457
|
+
// re-runs group metadata and usync rather than repeating the miss.
|
|
1470
1458
|
if (dispatchResult && dispatchResult.phash && dispatchResult.phash !== phash) {
|
|
1471
1459
|
_whaDbg('[DBG] GROUP_PHASH_MISMATCH ours=' + phash +
|
|
1472
1460
|
' server=' + dispatchResult.phash + ' group=' + groupJid +
|
|
@@ -1485,7 +1473,7 @@ class MessageSender {
|
|
|
1485
1473
|
|
|
1486
1474
|
// ─── Dispatch and wait for ack ────────────────────────────────────────────
|
|
1487
1475
|
//
|
|
1488
|
-
// Error 463 recovery
|
|
1476
|
+
// Error 463 recovery:
|
|
1489
1477
|
// 1. 463 ack arrives → issue privacy token via _issuePrivacyTokens
|
|
1490
1478
|
// 2. Wait for token to be stored in _tcTokenStore
|
|
1491
1479
|
// 3. Re-send the SAME message node with a new msgId — the tctoken node
|
|
@@ -1496,7 +1484,7 @@ class MessageSender {
|
|
|
1496
1484
|
//
|
|
1497
1485
|
// Uses a SEPARATE _inFlight463Recoveries Set (not shared with the proactive
|
|
1498
1486
|
// _inFlightTcTokenIssuance) so 463 recovery and proactive issuance never
|
|
1499
|
-
// block each other.
|
|
1487
|
+
// block each other.
|
|
1500
1488
|
|
|
1501
1489
|
_dispatchAndAck(node, msgId, _isRetry) {
|
|
1502
1490
|
const self = this;
|
|
@@ -1659,8 +1647,8 @@ class MessageSender {
|
|
|
1659
1647
|
key: { remoteJid: toJid, fromMe: !!fromMe, id: origMsgId }
|
|
1660
1648
|
});
|
|
1661
1649
|
const msgBuf = encodeMessage('protocol', revokePayload);
|
|
1662
|
-
// ProtocolMessage
|
|
1663
|
-
//
|
|
1650
|
+
// A ProtocolMessage travels as stanza type "text"; there is no "protocol"
|
|
1651
|
+
// stanza type.
|
|
1664
1652
|
// _protocol marks it for the tcToken issuance gate — see _sendDMMessage.
|
|
1665
1653
|
return this._sendMessage(toJid, msgId, msgBuf, 'text',
|
|
1666
1654
|
{ ...opts, edit: editBit, _protocol: true });
|
|
@@ -16,10 +16,9 @@
|
|
|
16
16
|
// not change the token. REPORTING_FIELDS below is that subset, and it is the
|
|
17
17
|
// part that has to match byte for byte or the token is worthless.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// token when a report is actually filed, neither choice is observable on send.
|
|
19
|
+
// The key is derived using the destination JID for both halves. Since the
|
|
20
|
+
// server only checks the token when a report is actually filed, the choice is
|
|
21
|
+
// not observable on send.
|
|
23
22
|
|
|
24
23
|
const crypto = require('crypto');
|
|
25
24
|
const { hkdf } = require('@noble/hashes/hkdf');
|
|
@@ -121,8 +120,7 @@ function encodeVarint(value) {
|
|
|
121
120
|
// The result is sorted by field number, which is what makes the token
|
|
122
121
|
// independent of the order the encoder happened to write the fields in.
|
|
123
122
|
//
|
|
124
|
-
// Returns null on malformed input — the caller then attaches no token
|
|
125
|
-
// the same thing Baileys does.
|
|
123
|
+
// Returns null on malformed input — the caller then attaches no token.
|
|
126
124
|
function extractReportingTokenContent(data, cfg) {
|
|
127
125
|
const out = [];
|
|
128
126
|
let i = 0;
|
|
@@ -3,16 +3,14 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
// 7-day buckets, 4 buckets = ~28-day rolling window
|
|
6
|
+
// 7-day buckets, 4 buckets = ~28-day rolling window
|
|
7
7
|
const TC_BUCKET_DURATION = 604800;
|
|
8
8
|
const TC_NUM_BUCKETS = 4;
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Expired rows are pruned out of the token store at most once a day; it is a
|
|
10
|
+
// JSON file, so there is nothing to gain from doing it more often.
|
|
11
11
|
const TC_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
12
|
|
|
13
13
|
// The two phone-number ranges WhatsApp uses for bots, including Meta AI.
|
|
14
|
-
// Byte-for-byte the same expression in whatsmeow (botUserRegex) and in
|
|
15
|
-
// Baileys (BOT_PHONE_REGEX).
|
|
16
14
|
const BOT_USER_RE = /^1313555\d{4}$|^131655500\d{2}$/;
|
|
17
15
|
|
|
18
16
|
function tcTokenExpired(timestamp) {
|
|
@@ -35,9 +33,8 @@ function shouldSendNewTcToken(senderTimestamp) {
|
|
|
35
33
|
|
|
36
34
|
// Whether a JID is a real person we may hold a trusted-contact token for.
|
|
37
35
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// a user address (phone or LID), not the PSA pseudo-contact 0@…, not a bot.
|
|
36
|
+
// The rule is: a user address (phone or LID), not the PSA pseudo-contact 0@…,
|
|
37
|
+
// and not a bot.
|
|
41
38
|
// Without it we issue a privacy IQ to WhatsApp's own announcement account and
|
|
42
39
|
// to Meta AI, which no real client ever does.
|
|
43
40
|
function isRegularTcTokenUser(jid) {
|
|
@@ -78,10 +75,8 @@ class TcTokenStore {
|
|
|
78
75
|
|
|
79
76
|
// Drop entries that are past the 28-day window on both counts.
|
|
80
77
|
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// whose token has expired but whose senderTimestamp is still inside the
|
|
84
|
-
// window has to stay: that timestamp is what keeps us from re-issuing on
|
|
78
|
+
// An entry whose token has expired but whose senderTimestamp is still inside
|
|
79
|
+
// the window has to stay: that timestamp is what keeps us from re-issuing on
|
|
85
80
|
// every single send.
|
|
86
81
|
pruneExpired() {
|
|
87
82
|
let removed = 0;
|
|
@@ -134,15 +129,14 @@ class TcTokenStore {
|
|
|
134
129
|
}
|
|
135
130
|
|
|
136
131
|
get(jid) {
|
|
137
|
-
//
|
|
138
|
-
// to once a day. Same here.
|
|
132
|
+
// Pruning happens on the read path, rate-limited to once a day.
|
|
139
133
|
this._maybePrune();
|
|
140
134
|
return this._map.get(normalizeJidForTcToken(jid)) || null;
|
|
141
135
|
}
|
|
142
136
|
|
|
143
137
|
// Returns true when the token was accepted.
|
|
144
138
|
//
|
|
145
|
-
// Two rejections
|
|
139
|
+
// Two rejections:
|
|
146
140
|
// - a token with no timestamp is already expired by definition, so storing
|
|
147
141
|
// it only wastes a slot and guarantees a miss on the next send;
|
|
148
142
|
// - a token older than the one on file must never overwrite it. Token
|