whalibmob 5.7.0 → 5.7.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 +48 -0
- package/cli.js +14 -8
- package/lib/Client.js +79 -1
- package/lib/messages/MessageSender.js +25 -0
- package/lib/messages/ReportingToken.js +364 -0
- package/lib/signal/SenderKey.js +20 -4
- package/lib/signal/SignalStore.js +24 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ npm install -g whalibmob
|
|
|
150
150
|
- [Handling Reconnects](#handling-reconnects)
|
|
151
151
|
- [Knowing Which Mode You Are In](#knowing-which-mode-you-are-in)
|
|
152
152
|
- [Two Sessions on One Number](#two-sessions-on-one-number)
|
|
153
|
+
- [The Number WhatsApp Files Your Account Under](#the-number-whatsapp-files-your-account-under)
|
|
153
154
|
- [Routing Registration Through a Proxy](#routing-registration-through-a-proxy)
|
|
154
155
|
- [Saving & Restoring Sessions](#saving--restoring-sessions)
|
|
155
156
|
- [Signal Store Utilities](#signal-store-utilities)
|
|
@@ -807,6 +808,53 @@ await client.connectWeb(PHONE) // web / companion, over WebSocket
|
|
|
807
808
|
|
|
808
809
|
Use two `WhalibmobClient` instances if you want both at once.
|
|
809
810
|
|
|
811
|
+
## The Number WhatsApp Files Your Account Under
|
|
812
|
+
|
|
813
|
+
WhatsApp does not always keep an account under the number you type. Brazilian mobiles are the standing example: they gained a ninth digit, and WhatsApp keeps older accounts under the eight-digit form. `5596976042705` and `559676042705` are the same account, but only the second is the one the server recognises.
|
|
814
|
+
|
|
815
|
+
This matters because the number in your session is sent as the username on every connection. Get the form wrong and the server has no registration matching it, so the login is refused with `401` — a code that says "logged out" and gives no hint that the number was the problem.
|
|
816
|
+
|
|
817
|
+
whalibmob handles this on its own, in both directions:
|
|
818
|
+
|
|
819
|
+
**When you register**, the canonical form is read from the server's reply and the session is saved under it. Numbers registered through whalibmob cannot end up in this state.
|
|
820
|
+
|
|
821
|
+
**When you connect** an older session that is in this state, the first login is refused, whalibmob asks the server which form it uses, re-files the session, and connects. You see one line:
|
|
822
|
+
|
|
823
|
+
```
|
|
824
|
+
connecting to +5596976042705...
|
|
825
|
+
this account is registered as +559676042705 (not +5596976042705) — session updated
|
|
826
|
+
connected as +559676042705
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
The rename keeps the registration and the Signal keys — it moves the session files, it does not re-register anything.
|
|
830
|
+
|
|
831
|
+
```js
|
|
832
|
+
client.on('number_corrected', ({ from, to }) => {
|
|
833
|
+
console.log('session re-filed:', from, '→', to)
|
|
834
|
+
})
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
To be told rather than fixed:
|
|
838
|
+
|
|
839
|
+
```js
|
|
840
|
+
const client = new WhalibmobClient({ sessionDir, autoFixNumber: false })
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
Then the `401` is thrown as-is, and `checkSessionAlive()` tells you what the server calls the account:
|
|
844
|
+
|
|
845
|
+
```js
|
|
846
|
+
const probe = await client.checkSessionAlive()
|
|
847
|
+
// { alive: true, status: 'ok', current: '5596976042705',
|
|
848
|
+
// canonical: '559676042705', mismatch: true }
|
|
849
|
+
|
|
850
|
+
if (probe.mismatch) {
|
|
851
|
+
await client.adoptCanonicalNumber(probe.canonical)
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
> [!NOTE]
|
|
856
|
+
> The check costs nothing when things work — it runs only after a login has already been refused.
|
|
857
|
+
|
|
810
858
|
## Routing Registration Through a Proxy
|
|
811
859
|
|
|
812
860
|
Registration is the part of the protocol most likely to be refused from a datacenter IP. If you are seeing security blocks or an undeterminable number status, route it through a SOCKS proxy — Tor, or a residential provider.
|
package/cli.js
CHANGED
|
@@ -747,6 +747,14 @@ async function doConnect(phone) {
|
|
|
747
747
|
const client = new WhalibmobClient({ sessionDir: _sessDir });
|
|
748
748
|
attachEvents(client);
|
|
749
749
|
|
|
750
|
+
// The session may be re-filed mid-connect when WhatsApp turns out to keep the
|
|
751
|
+
// account under a different form of the number. Everything from here on has
|
|
752
|
+
// to use the corrected one, including the prompt.
|
|
753
|
+
client.on('number_corrected', ({ from, to }) => {
|
|
754
|
+
phone = to;
|
|
755
|
+
out(' this account is registered as +' + to + ' (not +' + from + ') — session updated');
|
|
756
|
+
});
|
|
757
|
+
|
|
750
758
|
client.once('connected', () => {
|
|
751
759
|
_client = client;
|
|
752
760
|
_phone = phone;
|
|
@@ -783,19 +791,17 @@ async function doConnect(phone) {
|
|
|
783
791
|
const probe = await client.checkSessionAlive();
|
|
784
792
|
|
|
785
793
|
if (probe.mismatch) {
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
794
|
+
// Reached only when the automatic correction inside init() could not
|
|
795
|
+
// run — a session already exists under the canonical number, or the
|
|
796
|
+
// rename failed. Say what it would have done.
|
|
789
797
|
hr();
|
|
790
798
|
out(' the session is filed under a number WhatsApp does not use');
|
|
791
799
|
out(' session : +' + probe.current);
|
|
792
800
|
out(' server : +' + probe.canonical);
|
|
793
801
|
hr();
|
|
794
|
-
out('
|
|
795
|
-
out('
|
|
796
|
-
out('');
|
|
797
|
-
out(' fix it in place: /fixnumber');
|
|
798
|
-
out(' (renames the session files, keeps the registration)');
|
|
802
|
+
out(' this is normally corrected automatically; it could not be here.');
|
|
803
|
+
out(' check whether a session for +' + probe.canonical + ' already exists,');
|
|
804
|
+
out(' then: /fixnumber');
|
|
799
805
|
} else if (probe.alive) {
|
|
800
806
|
out(' the number IS still registered, but this device was logged out.');
|
|
801
807
|
out(' register it again: /reg code ' + phone);
|
package/lib/Client.js
CHANGED
|
@@ -203,6 +203,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
203
203
|
// client the server will not accept. Suppresses the reconnect loop.
|
|
204
204
|
this._fatal = false;
|
|
205
205
|
this._closing = false;
|
|
206
|
+
// A session whose number is not in WhatsApp's canonical form is re-filed
|
|
207
|
+
// automatically on the first refused login. Set { autoFixNumber: false } to
|
|
208
|
+
// be told about it instead.
|
|
209
|
+
this._autoFixNumber = opts.autoFixNumber !== false;
|
|
210
|
+
this._numberFixAttempted = false;
|
|
206
211
|
this._pingTimer = null;
|
|
207
212
|
this._keepTimer = null;
|
|
208
213
|
this._connected = false;
|
|
@@ -316,10 +321,83 @@ class WhalibmobClient extends EventEmitter {
|
|
|
316
321
|
}
|
|
317
322
|
_whaDbg('[DBG] LID_RESTORED count=' + Object.keys(persistedLid).length);
|
|
318
323
|
|
|
319
|
-
|
|
324
|
+
try {
|
|
325
|
+
await this._connectSocket();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// A session filed under a number WhatsApp does not use fails here and
|
|
328
|
+
// only here, with a 401 that says nothing about the number. Since we can
|
|
329
|
+
// ask the server which form it uses, there is no reason to make anyone
|
|
330
|
+
// find that out by hand.
|
|
331
|
+
const fixed = await this._tryCorrectPhoneNumber(err);
|
|
332
|
+
if (!fixed) throw err;
|
|
333
|
+
return this.init(fixed);
|
|
334
|
+
}
|
|
320
335
|
return this;
|
|
321
336
|
}
|
|
322
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Last resort before reporting a refused login: check whether the session is
|
|
340
|
+
* simply filed under the wrong form of the number, and re-file it if so.
|
|
341
|
+
*
|
|
342
|
+
* Returns the corrected number when it did something, null otherwise. Only
|
|
343
|
+
* ever runs once per client — a second 401 after a rename is a real one.
|
|
344
|
+
*/
|
|
345
|
+
async _tryCorrectPhoneNumber(err) {
|
|
346
|
+
if (this._autoFixNumber === false) return null;
|
|
347
|
+
if (this._numberFixAttempted) return null;
|
|
348
|
+
if (!err || !err.loggedOut || String(err.code) !== '401') return null;
|
|
349
|
+
this._numberFixAttempted = true;
|
|
350
|
+
|
|
351
|
+
let probe;
|
|
352
|
+
try {
|
|
353
|
+
probe = await this.checkSessionAlive();
|
|
354
|
+
} catch (_) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
if (!probe || !probe.mismatch || !probe.canonical) return null;
|
|
358
|
+
|
|
359
|
+
_whaDbg('[DBG] AUTO_FIX_NUMBER ' + probe.current + ' → ' + probe.canonical);
|
|
360
|
+
|
|
361
|
+
// Let go of the session files before moving them. The Signal store writes
|
|
362
|
+
// on a debounce, so a pending write would recreate the old file moments
|
|
363
|
+
// after the rename and leave two half-sessions on disk.
|
|
364
|
+
this._releaseSessionFiles();
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
await this.adoptCanonicalNumber(probe.canonical);
|
|
368
|
+
} catch (renameErr) {
|
|
369
|
+
_whaDbg('[DBG] AUTO_FIX_NUMBER failed: ' + renameErr.message);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.emit('number_corrected', {
|
|
374
|
+
from: probe.current,
|
|
375
|
+
to: probe.canonical,
|
|
376
|
+
reason: 'WhatsApp files this account under a different form of the number'
|
|
377
|
+
});
|
|
378
|
+
return probe.canonical;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Flush and detach whatever is holding the current session's files open, so
|
|
382
|
+
// they can be renamed without a queued write putting one of them back.
|
|
383
|
+
_releaseSessionFiles() {
|
|
384
|
+
for (const store of [this._signal && this._signal.store,
|
|
385
|
+
this._signal && this._signal.senderKeyStore]) {
|
|
386
|
+
if (!store) continue;
|
|
387
|
+
try {
|
|
388
|
+
if (typeof store.detach === 'function') store.detach();
|
|
389
|
+
else {
|
|
390
|
+
if (typeof store._flushSync === 'function') store._flushSync();
|
|
391
|
+
if (store._saveTimer) { clearTimeout(store._saveTimer); store._saveTimer = null; }
|
|
392
|
+
store._filePath = null;
|
|
393
|
+
}
|
|
394
|
+
} catch (_) {}
|
|
395
|
+
}
|
|
396
|
+
this._signal = null;
|
|
397
|
+
this._devMgr = null;
|
|
398
|
+
this._tcTokenStore = null;
|
|
399
|
+
}
|
|
400
|
+
|
|
323
401
|
async _connectSocket() {
|
|
324
402
|
if (this._mode === 'web') return this._connectWebSocket();
|
|
325
403
|
|
|
@@ -25,6 +25,7 @@ const { uploadMedia } = require('../MediaService');
|
|
|
25
25
|
const { imageThumbnail, videoThumbnail, probeImageSize } = require('../MediaThumbnail');
|
|
26
26
|
const { tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken,
|
|
27
27
|
isRegularTcTokenUser } = require('./TcTokenStore');
|
|
28
|
+
const { buildReportingNode } = require('./ReportingToken');
|
|
28
29
|
|
|
29
30
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
@@ -1014,6 +1015,19 @@ class MessageSender {
|
|
|
1014
1015
|
msgContent = [...devIdNodes, participantsNode];
|
|
1015
1016
|
}
|
|
1016
1017
|
|
|
1018
|
+
// ─── Reporting token ─────────────────────────────────────────────────────
|
|
1019
|
+
// Rides on any message carrying a message secret, so a recipient can report
|
|
1020
|
+
// it and have the server verify the reported text is what was actually
|
|
1021
|
+
// sent. Sending the secret without the token is a combination no real
|
|
1022
|
+
// client produces.
|
|
1023
|
+
const reportingNode = buildReportingNode({
|
|
1024
|
+
plaintext, msgId, remoteJid: routingToJid, BinaryNode
|
|
1025
|
+
});
|
|
1026
|
+
if (reportingNode) {
|
|
1027
|
+
msgContent.push(reportingNode);
|
|
1028
|
+
_whaDbg('[DBG] REPORTING_TOKEN attached to=' + routingToJid + ' id=' + msgId);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1017
1031
|
// ─── tcToken (error 463 / reachout timelock defense) ─────────────────────
|
|
1018
1032
|
// WhatsApp counts every DM without a <tctoken> node as a "reaching out"
|
|
1019
1033
|
// event. Once enough accumulate the server returns error 463 (timelock).
|
|
@@ -1372,6 +1386,17 @@ class MessageSender {
|
|
|
1372
1386
|
if (mediaSubtype) skmsgAttrs.mediatype = mediaSubtype;
|
|
1373
1387
|
msgContent.push(new BinaryNode('enc', skmsgAttrs, Buffer.isBuffer(skmsgCiphertext) ? skmsgCiphertext : Buffer.from(skmsgCiphertext)));
|
|
1374
1388
|
|
|
1389
|
+
// Same reporting token a direct message carries. A poll posted to a group
|
|
1390
|
+
// is the common case: it has a message secret, so it needs the token that
|
|
1391
|
+
// lets a report on it be verified.
|
|
1392
|
+
const groupReportingNode = buildReportingNode({
|
|
1393
|
+
plaintext, msgId, remoteJid: groupJid, BinaryNode
|
|
1394
|
+
});
|
|
1395
|
+
if (groupReportingNode) {
|
|
1396
|
+
msgContent.push(groupReportingNode);
|
|
1397
|
+
_whaDbg('[DBG] REPORTING_TOKEN attached to=' + groupJid + ' id=' + msgId);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1375
1400
|
// phash belongs on a group stanza and only on a group stanza — whatsmeow's
|
|
1376
1401
|
// sendGroup sets it, sendDM does not. It tells the server which device set we
|
|
1377
1402
|
// fanned out to; the server compares it against its own and echoes its hash
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The reporting token that rides on messages carrying a message secret.
|
|
4
|
+
//
|
|
5
|
+
// It exists so a message can be reported for abuse without the server being
|
|
6
|
+
// able to read it. The sender attaches an HMAC over the message's own content,
|
|
7
|
+
// keyed by material derived from the message secret. If the recipient later
|
|
8
|
+
// reports the message, they hand the server the plaintext; the server cannot
|
|
9
|
+
// decrypt anything itself, but it can recompute the HMAC and confirm the text
|
|
10
|
+
// being reported is exactly what was sent rather than something the reporter
|
|
11
|
+
// made up.
|
|
12
|
+
//
|
|
13
|
+
// The HMAC is not taken over the whole message. WhatsApp hashes a fixed subset
|
|
14
|
+
// of protobuf fields — the ones that carry reportable content — copied out in
|
|
15
|
+
// field-number order, so that fields which vary between devices or over time do
|
|
16
|
+
// not change the token. REPORTING_FIELDS below is that subset, and it is the
|
|
17
|
+
// part that has to match byte for byte or the token is worthless.
|
|
18
|
+
//
|
|
19
|
+
// Ported field-for-field from Baileys' reporting-utils. whatsmeow derives the
|
|
20
|
+
// key from (ownID, to) where Baileys uses the destination JID for both halves;
|
|
21
|
+
// the reference followed here is Baileys, and since the server only checks the
|
|
22
|
+
// token when a report is actually filed, neither choice is observable on send.
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
const { hkdf } = require('@noble/hashes/hkdf');
|
|
26
|
+
const { sha256 } = require('@noble/hashes/sha256');
|
|
27
|
+
|
|
28
|
+
// Protobuf field numbers on Message that the token is computed over.
|
|
29
|
+
// f field number
|
|
30
|
+
// m descend into it but keep every subfield
|
|
31
|
+
// s descend into it and keep only these subfields
|
|
32
|
+
const REPORTING_FIELDS = [
|
|
33
|
+
{ f: 1 },
|
|
34
|
+
{ f: 3, s: [{ f: 2 }, { f: 3 }, { f: 8 }, { f: 11 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 25 }] },
|
|
35
|
+
{ f: 4, s: [{ f: 1 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] },
|
|
36
|
+
{ f: 5, s: [{ f: 3 }, { f: 4 }, { f: 5 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] },
|
|
37
|
+
{ f: 6, s: [{ f: 1 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 30 }] },
|
|
38
|
+
{ f: 7, s: [{ f: 2 }, { f: 7 }, { f: 10 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] },
|
|
39
|
+
{ f: 8, s: [{ f: 2 }, { f: 7 }, { f: 9 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 21 }] },
|
|
40
|
+
{ f: 9, s: [{ f: 2 }, { f: 6 }, { f: 7 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] },
|
|
41
|
+
{ f: 12, s: [{ f: 1 }, { f: 2 }, { f: 14, m: true }, { f: 15 }] },
|
|
42
|
+
{ f: 18, s: [{ f: 6 }, { f: 16 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] },
|
|
43
|
+
{ f: 26, s: [{ f: 4 }, { f: 5 }, { f: 8 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }] },
|
|
44
|
+
{ f: 28, s: [{ f: 1 }, { f: 2 }, { f: 4 }, { f: 5 }, { f: 6 }, { f: 7, s: [{ f: 21 }, { f: 22 }] }] },
|
|
45
|
+
{ f: 37, s: [{ f: 1, m: true }] },
|
|
46
|
+
{ f: 49, s: [{ f: 2 }, { f: 3, s: [{ f: 1 }, { f: 2 }] }, { f: 5, s: [{ f: 21 }, { f: 22 }] },
|
|
47
|
+
{ f: 8, s: [{ f: 1 }, { f: 2 }] }] },
|
|
48
|
+
{ f: 53, s: [{ f: 1, m: true }] },
|
|
49
|
+
{ f: 55, s: [{ f: 1, m: true }] },
|
|
50
|
+
{ f: 58, s: [{ f: 1, m: true }] },
|
|
51
|
+
{ f: 59, s: [{ f: 1, m: true }] },
|
|
52
|
+
{ f: 60, s: [{ f: 2 }, { f: 3, s: [{ f: 1 }, { f: 2 }] }, { f: 5, s: [{ f: 21 }, { f: 22 }] },
|
|
53
|
+
{ f: 8, s: [{ f: 1 }, { f: 2 }] }] },
|
|
54
|
+
{ f: 64, s: [{ f: 2 }, { f: 3, s: [{ f: 1 }, { f: 2 }] }, { f: 5, s: [{ f: 21 }, { f: 22 }] },
|
|
55
|
+
{ f: 8, s: [{ f: 1 }, { f: 2 }] }] },
|
|
56
|
+
{ f: 66, s: [{ f: 2 }, { f: 6 }, { f: 7 }, { f: 13 }, { f: 17, s: [{ f: 21 }, { f: 22 }] }, { f: 20 }] },
|
|
57
|
+
{ f: 74, s: [{ f: 1, m: true }] },
|
|
58
|
+
{ f: 87, s: [{ f: 1, m: true }] },
|
|
59
|
+
{ f: 88, s: [{ f: 1 }, { f: 2, s: [{ f: 1 }] }, { f: 3, s: [{ f: 21 }, { f: 22 }] }] },
|
|
60
|
+
{ f: 92, s: [{ f: 1, m: true }] },
|
|
61
|
+
{ f: 93, s: [{ f: 1, m: true }] },
|
|
62
|
+
{ f: 94, s: [{ f: 1, m: true }] }
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function compileFields(fields) {
|
|
66
|
+
const map = new Map();
|
|
67
|
+
for (const f of fields) {
|
|
68
|
+
map.set(f.f, { m: f.m, children: f.s ? compileFields(f.s) : undefined });
|
|
69
|
+
}
|
|
70
|
+
return map;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const COMPILED = compileFields(REPORTING_FIELDS);
|
|
74
|
+
const EMPTY_MAP = new Map();
|
|
75
|
+
|
|
76
|
+
const ENC_SECRET_REPORT_TOKEN = 'Report Token';
|
|
77
|
+
|
|
78
|
+
const WIRE = { VARINT: 0, FIXED64: 1, BYTES: 2, FIXED32: 5 };
|
|
79
|
+
|
|
80
|
+
// Message field numbers that matter here.
|
|
81
|
+
const F_MESSAGE_CONTEXT_INFO = 35;
|
|
82
|
+
const F_REACTION = 46;
|
|
83
|
+
const F_POLL_UPDATE = 50;
|
|
84
|
+
const F_ENC_REACTION = 56;
|
|
85
|
+
const F_ENC_EVENT_RESPONSE = 76;
|
|
86
|
+
const F_CTX_MESSAGE_SECRET = 3; // inside MessageContextInfo
|
|
87
|
+
|
|
88
|
+
// ─── varints ─────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function decodeVarint(buffer, offset) {
|
|
91
|
+
let value = 0, bytes = 0, shift = 0;
|
|
92
|
+
while (offset + bytes < buffer.length) {
|
|
93
|
+
const current = buffer[offset + bytes];
|
|
94
|
+
value |= (current & 0x7f) << shift;
|
|
95
|
+
bytes++;
|
|
96
|
+
if ((current & 0x80) === 0) return { value, bytes, ok: true };
|
|
97
|
+
shift += 7;
|
|
98
|
+
if (shift > 35) return { value: 0, bytes: 0, ok: false };
|
|
99
|
+
}
|
|
100
|
+
return { value: 0, bytes: 0, ok: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function encodeVarint(value) {
|
|
104
|
+
const parts = [];
|
|
105
|
+
let remaining = value >>> 0;
|
|
106
|
+
while (remaining > 0x7f) {
|
|
107
|
+
parts.push((remaining & 0x7f) | 0x80);
|
|
108
|
+
remaining >>>= 7;
|
|
109
|
+
}
|
|
110
|
+
parts.push(remaining);
|
|
111
|
+
return Buffer.from(parts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── selective protobuf extraction ───────────────────────────────────────────
|
|
115
|
+
//
|
|
116
|
+
// Walks the encoded message and copies out only the configured fields, keeping
|
|
117
|
+
// their original bytes. Length-delimited fields with a config of their own are
|
|
118
|
+
// re-encoded around whatever their own walk produced, so a nested field that
|
|
119
|
+
// contributes nothing disappears entirely rather than leaving an empty wrapper.
|
|
120
|
+
//
|
|
121
|
+
// The result is sorted by field number, which is what makes the token
|
|
122
|
+
// independent of the order the encoder happened to write the fields in.
|
|
123
|
+
//
|
|
124
|
+
// Returns null on malformed input — the caller then attaches no token, which is
|
|
125
|
+
// the same thing Baileys does.
|
|
126
|
+
function extractReportingTokenContent(data, cfg) {
|
|
127
|
+
const out = [];
|
|
128
|
+
let i = 0;
|
|
129
|
+
|
|
130
|
+
while (i < data.length) {
|
|
131
|
+
const tag = decodeVarint(data, i);
|
|
132
|
+
if (!tag.ok) return null;
|
|
133
|
+
|
|
134
|
+
const fieldNum = tag.value >> 3;
|
|
135
|
+
const wireType = tag.value & 0x7;
|
|
136
|
+
const fieldStart = i;
|
|
137
|
+
i += tag.bytes;
|
|
138
|
+
|
|
139
|
+
const fieldCfg = cfg.get(fieldNum);
|
|
140
|
+
|
|
141
|
+
const pushSlice = (end) => {
|
|
142
|
+
if (end > data.length) return false;
|
|
143
|
+
out.push({ num: fieldNum, bytes: data.subarray(fieldStart, end) });
|
|
144
|
+
i = end;
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
const skip = (end) => {
|
|
148
|
+
if (end > data.length) return false;
|
|
149
|
+
i = end;
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (wireType === WIRE.VARINT) {
|
|
154
|
+
const v = decodeVarint(data, i);
|
|
155
|
+
if (!v.ok) return null;
|
|
156
|
+
const end = i + v.bytes;
|
|
157
|
+
if (!fieldCfg) { if (!skip(end)) return null; continue; }
|
|
158
|
+
if (!pushSlice(end)) return null;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (wireType === WIRE.FIXED64) {
|
|
163
|
+
const end = i + 8;
|
|
164
|
+
if (!fieldCfg) { if (!skip(end)) return null; continue; }
|
|
165
|
+
if (!pushSlice(end)) return null;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (wireType === WIRE.FIXED32) {
|
|
170
|
+
const end = i + 4;
|
|
171
|
+
if (!fieldCfg) { if (!skip(end)) return null; continue; }
|
|
172
|
+
if (!pushSlice(end)) return null;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (wireType === WIRE.BYTES) {
|
|
177
|
+
const len = decodeVarint(data, i);
|
|
178
|
+
if (!len.ok) return null;
|
|
179
|
+
const valStart = i + len.bytes;
|
|
180
|
+
const valEnd = valStart + len.value;
|
|
181
|
+
if (valEnd > data.length) return null;
|
|
182
|
+
|
|
183
|
+
if (!fieldCfg) { i = valEnd; continue; }
|
|
184
|
+
|
|
185
|
+
if (fieldCfg.m || fieldCfg.children) {
|
|
186
|
+
const sub = extractReportingTokenContent(
|
|
187
|
+
data.subarray(valStart, valEnd), fieldCfg.children || EMPTY_MAP
|
|
188
|
+
);
|
|
189
|
+
if (sub === null) return null;
|
|
190
|
+
if (sub.length > 0) {
|
|
191
|
+
out.push({
|
|
192
|
+
num: fieldNum,
|
|
193
|
+
bytes: Buffer.concat([encodeVarint(tag.value), encodeVarint(sub.length), sub])
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
i = valEnd;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
out.push({ num: fieldNum, bytes: data.subarray(fieldStart, valEnd) });
|
|
201
|
+
i = valEnd;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (out.length === 0) return Buffer.alloc(0);
|
|
209
|
+
out.sort((a, b) => a.num - b.num);
|
|
210
|
+
return Buffer.concat(out.map(f => f.bytes));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── key derivation ──────────────────────────────────────────────────────────
|
|
214
|
+
//
|
|
215
|
+
// The same construction WhatsApp uses for every secret derived from a message
|
|
216
|
+
// secret — poll votes, reactions, and this. The use-case string is what keeps
|
|
217
|
+
// them from colliding.
|
|
218
|
+
function generateMsgSecretKey(modificationType, origMsgId, origMsgSender, modificationSender, origMsgSecret) {
|
|
219
|
+
const useCaseSecret = Buffer.concat([
|
|
220
|
+
Buffer.from(String(origMsgId), 'utf8'),
|
|
221
|
+
Buffer.from(String(origMsgSender), 'utf8'),
|
|
222
|
+
Buffer.from(String(modificationSender), 'utf8'),
|
|
223
|
+
Buffer.from(modificationType, 'utf8')
|
|
224
|
+
]);
|
|
225
|
+
return Buffer.from(hkdf(sha256, origMsgSecret, Buffer.alloc(32), useCaseSecret, 32));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── reading what we need out of the encoded message ─────────────────────────
|
|
229
|
+
|
|
230
|
+
// Shallow scan for the top-level field numbers present, plus the message
|
|
231
|
+
// secret if MessageContextInfo carries one. Avoids decoding the whole message
|
|
232
|
+
// just to answer two questions about it.
|
|
233
|
+
function inspectMessage(plaintext) {
|
|
234
|
+
const present = new Set();
|
|
235
|
+
let messageSecret = null;
|
|
236
|
+
let i = 0;
|
|
237
|
+
|
|
238
|
+
while (i < plaintext.length) {
|
|
239
|
+
const tag = decodeVarint(plaintext, i);
|
|
240
|
+
if (!tag.ok) return { present, messageSecret, ok: false };
|
|
241
|
+
const fieldNum = tag.value >> 3;
|
|
242
|
+
const wireType = tag.value & 0x7;
|
|
243
|
+
i += tag.bytes;
|
|
244
|
+
present.add(fieldNum);
|
|
245
|
+
|
|
246
|
+
if (wireType === WIRE.VARINT) {
|
|
247
|
+
const v = decodeVarint(plaintext, i);
|
|
248
|
+
if (!v.ok) return { present, messageSecret, ok: false };
|
|
249
|
+
i += v.bytes;
|
|
250
|
+
} else if (wireType === WIRE.FIXED64) {
|
|
251
|
+
i += 8;
|
|
252
|
+
} else if (wireType === WIRE.FIXED32) {
|
|
253
|
+
i += 4;
|
|
254
|
+
} else if (wireType === WIRE.BYTES) {
|
|
255
|
+
const len = decodeVarint(plaintext, i);
|
|
256
|
+
if (!len.ok) return { present, messageSecret, ok: false };
|
|
257
|
+
const valStart = i + len.bytes;
|
|
258
|
+
const valEnd = valStart + len.value;
|
|
259
|
+
if (valEnd > plaintext.length) return { present, messageSecret, ok: false };
|
|
260
|
+
if (fieldNum === F_MESSAGE_CONTEXT_INFO) {
|
|
261
|
+
messageSecret = readMessageSecret(plaintext.subarray(valStart, valEnd));
|
|
262
|
+
}
|
|
263
|
+
i = valEnd;
|
|
264
|
+
} else {
|
|
265
|
+
return { present, messageSecret, ok: false };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { present, messageSecret, ok: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readMessageSecret(ctxBuf) {
|
|
272
|
+
let i = 0;
|
|
273
|
+
while (i < ctxBuf.length) {
|
|
274
|
+
const tag = decodeVarint(ctxBuf, i);
|
|
275
|
+
if (!tag.ok) return null;
|
|
276
|
+
const fieldNum = tag.value >> 3;
|
|
277
|
+
const wireType = tag.value & 0x7;
|
|
278
|
+
i += tag.bytes;
|
|
279
|
+
|
|
280
|
+
if (wireType === WIRE.VARINT) {
|
|
281
|
+
const v = decodeVarint(ctxBuf, i);
|
|
282
|
+
if (!v.ok) return null;
|
|
283
|
+
i += v.bytes;
|
|
284
|
+
} else if (wireType === WIRE.FIXED64) {
|
|
285
|
+
i += 8;
|
|
286
|
+
} else if (wireType === WIRE.FIXED32) {
|
|
287
|
+
i += 4;
|
|
288
|
+
} else if (wireType === WIRE.BYTES) {
|
|
289
|
+
const len = decodeVarint(ctxBuf, i);
|
|
290
|
+
if (!len.ok) return null;
|
|
291
|
+
const valStart = i + len.bytes;
|
|
292
|
+
const valEnd = valStart + len.value;
|
|
293
|
+
if (valEnd > ctxBuf.length) return null;
|
|
294
|
+
if (fieldNum === F_CTX_MESSAGE_SECRET) return ctxBuf.subarray(valStart, valEnd);
|
|
295
|
+
i = valEnd;
|
|
296
|
+
} else {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Whether a message of this shape carries a reporting token at all.
|
|
305
|
+
*
|
|
306
|
+
* Reactions and poll votes are excluded: they are modifications of somebody
|
|
307
|
+
* else's message, and the token belongs to the message being modified.
|
|
308
|
+
*/
|
|
309
|
+
function shouldIncludeReportingToken(present) {
|
|
310
|
+
return !present.has(F_REACTION) &&
|
|
311
|
+
!present.has(F_ENC_REACTION) &&
|
|
312
|
+
!present.has(F_ENC_EVENT_RESPONSE) &&
|
|
313
|
+
!present.has(F_POLL_UPDATE);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Build the <reporting> node for an outgoing message, or null when there is
|
|
318
|
+
* nothing to attach.
|
|
319
|
+
*
|
|
320
|
+
* @param {object} opts
|
|
321
|
+
* plaintext encoded Message protobuf, exactly as it will be encrypted
|
|
322
|
+
* msgId the stanza id
|
|
323
|
+
* remoteJid the destination JID
|
|
324
|
+
* participant set only on a targeted resend; undefined otherwise
|
|
325
|
+
* BinaryNode the node constructor
|
|
326
|
+
*/
|
|
327
|
+
function buildReportingNode(opts) {
|
|
328
|
+
const { plaintext, msgId, remoteJid, participant, BinaryNode } = opts;
|
|
329
|
+
if (!Buffer.isBuffer(plaintext) || !msgId || !remoteJid) return null;
|
|
330
|
+
|
|
331
|
+
const info = inspectMessage(plaintext);
|
|
332
|
+
if (!info.ok || !info.messageSecret || !info.messageSecret.length) return null;
|
|
333
|
+
if (!shouldIncludeReportingToken(info.present)) return null;
|
|
334
|
+
|
|
335
|
+
// fromMe is always true here — we only ever build this for our own sends.
|
|
336
|
+
const from = String(remoteJid);
|
|
337
|
+
const to = String(participant || remoteJid);
|
|
338
|
+
|
|
339
|
+
const reportingSecret = generateMsgSecretKey(
|
|
340
|
+
ENC_SECRET_REPORT_TOKEN, msgId, from, to, info.messageSecret
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const content = extractReportingTokenContent(plaintext, COMPILED);
|
|
344
|
+
if (!content || content.length === 0) return null;
|
|
345
|
+
|
|
346
|
+
const token = crypto.createHmac('sha256', reportingSecret)
|
|
347
|
+
.update(content).digest().subarray(0, 16);
|
|
348
|
+
|
|
349
|
+
return new BinaryNode('reporting', {}, [
|
|
350
|
+
new BinaryNode('reporting_token', { v: '2' }, token)
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
module.exports = {
|
|
355
|
+
buildReportingNode,
|
|
356
|
+
shouldIncludeReportingToken,
|
|
357
|
+
extractReportingTokenContent,
|
|
358
|
+
generateMsgSecretKey,
|
|
359
|
+
inspectMessage,
|
|
360
|
+
decodeVarint,
|
|
361
|
+
encodeVarint,
|
|
362
|
+
REPORTING_FIELDS,
|
|
363
|
+
COMPILED
|
|
364
|
+
};
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -93,10 +93,26 @@ class SenderKeyStore {
|
|
|
93
93
|
this._dirty = false;
|
|
94
94
|
this._saveTimer = null;
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
process.
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
// Detachable, for the same reason as in SignalStore: a replaced store must
|
|
97
|
+
// not keep three process listeners alive for the rest of the run.
|
|
98
|
+
this._onExit = () => this._flushSync();
|
|
99
|
+
this._onSigTerm = () => { this._flushSync(); process.exit(0); };
|
|
100
|
+
this._onSigInt = () => { this._flushSync(); process.exit(0); };
|
|
101
|
+
process.once('exit', this._onExit);
|
|
102
|
+
process.once('SIGTERM', this._onSigTerm);
|
|
103
|
+
process.once('SIGINT', this._onSigInt);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Flush, stop writing, and stop holding the process open. */
|
|
107
|
+
detach() {
|
|
108
|
+
this._flushSync();
|
|
109
|
+
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
110
|
+
try {
|
|
111
|
+
process.removeListener('exit', this._onExit);
|
|
112
|
+
process.removeListener('SIGTERM', this._onSigTerm);
|
|
113
|
+
process.removeListener('SIGINT', this._onSigInt);
|
|
114
|
+
} catch (_) {}
|
|
115
|
+
this._filePath = null;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
attachFile(filePath) {
|
|
@@ -36,11 +36,30 @@ class SignalStore {
|
|
|
36
36
|
this._dirty = false;
|
|
37
37
|
this._saveTimer = null;
|
|
38
38
|
|
|
39
|
-
// Flush synchronously on exit so no state is ever lost
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
process
|
|
39
|
+
// Flush synchronously on exit so no state is ever lost.
|
|
40
|
+
//
|
|
41
|
+
// Kept as references so they can be taken off again: a store that has been
|
|
42
|
+
// replaced — when a session is re-filed under a different number, say —
|
|
43
|
+
// would otherwise stay reachable from process forever, and every store ever
|
|
44
|
+
// built would add three more listeners.
|
|
45
|
+
this._onExit = () => this._flushSync();
|
|
46
|
+
this._onSigTerm = () => { this._flushSync(); process.exit(0); };
|
|
47
|
+
this._onSigInt = () => { this._flushSync(); process.exit(0); };
|
|
48
|
+
process.once('exit', this._onExit);
|
|
49
|
+
process.once('SIGTERM', this._onSigTerm);
|
|
50
|
+
process.once('SIGINT', this._onSigInt);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Flush, stop writing, and stop holding the process open. */
|
|
54
|
+
detach() {
|
|
55
|
+
this._flushSync();
|
|
56
|
+
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
57
|
+
try {
|
|
58
|
+
process.removeListener('exit', this._onExit);
|
|
59
|
+
process.removeListener('SIGTERM', this._onSigTerm);
|
|
60
|
+
process.removeListener('SIGINT', this._onSigInt);
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
this._filePath = null;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
init(identityKeyPair, registrationId) {
|