whalibmob 5.5.90 → 5.6.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 +463 -4
- package/cli.js +151 -5
- package/index.js +28 -0
- package/lib/Client.js +607 -62
- package/lib/CompanionPairing.js +247 -0
- package/lib/HistorySyncHandler.js +6 -1
- package/lib/MediaService.js +58 -12
- package/lib/PairingCode.js +198 -0
- package/lib/WebSocketStream.js +98 -0
- package/lib/WebStore.js +117 -0
- package/lib/constants.js +25 -0
- package/lib/messages/MessageSender.js +79 -19
- package/lib/messages/TcTokenStore.js +90 -3
- package/lib/noise.js +72 -7
- package/lib/signal/SignalProtocol.js +14 -5
- package/lib/webproto.js +273 -0
- package/package.json +3 -2
package/lib/WebStore.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Session state for a WhatsApp Web (companion) link.
|
|
4
|
+
//
|
|
5
|
+
// Deliberately the same shape as the mobile store: SignalProtocol,
|
|
6
|
+
// DeviceManager and MessageSender all read `noiseKeyPair`, `identityKeyPair`,
|
|
7
|
+
// `signedPreKey`, `registrationId` and `advIdentity` off it, and none of them
|
|
8
|
+
// should have to care which way the session was established. What web mode adds
|
|
9
|
+
// on top is the pairing material and the identity the server hands back:
|
|
10
|
+
//
|
|
11
|
+
// advSecretKey derived during pairing; every later proof that this
|
|
12
|
+
// device belongs to the account is an HMAC under it
|
|
13
|
+
// pairingEphemeralKeyPair one half of the code-authenticated exchange
|
|
14
|
+
// pairingCode the eight characters shown to the human
|
|
15
|
+
// me { id, lid, name } — the JID we were issued
|
|
16
|
+
// deviceIndex which linked-device slot we occupy; 0 is the phone
|
|
17
|
+
//
|
|
18
|
+
// Stored beside the mobile session as `<phone>.web.json`, so registering a
|
|
19
|
+
// number over SMS and linking it as a companion never overwrite each other.
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
|
|
25
|
+
const { createNewStore, storeToJson, storeFromJson } = require('./Store');
|
|
26
|
+
const { WEB_VERSION_FALLBACK, WEB_BROWSER } = require('./constants');
|
|
27
|
+
const { generateEphemeralKeyPair } = require('./PairingCode');
|
|
28
|
+
|
|
29
|
+
function webSessionPath(sessionDir, phoneNumber) {
|
|
30
|
+
return path.join(sessionDir, `${String(phoneNumber).replace(/\D/g, '')}.web.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createNewWebStore(phoneNumber) {
|
|
34
|
+
const store = createNewStore(phoneNumber);
|
|
35
|
+
|
|
36
|
+
store.mode = 'web';
|
|
37
|
+
store.registered = false;
|
|
38
|
+
store.advSecretKey = null;
|
|
39
|
+
store.pairingCode = null;
|
|
40
|
+
store.pairingEphemeralKeyPair = generateEphemeralKeyPair();
|
|
41
|
+
store.me = null;
|
|
42
|
+
store.deviceIndex = 0;
|
|
43
|
+
store.platform = null;
|
|
44
|
+
store.webVersion = WEB_VERSION_FALLBACK.slice();
|
|
45
|
+
store.browser = WEB_BROWSER.slice();
|
|
46
|
+
store.syncFullHistory = true;
|
|
47
|
+
|
|
48
|
+
return store;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function webStoreToJson(store) {
|
|
52
|
+
const base = storeToJson(store);
|
|
53
|
+
return Object.assign(base, {
|
|
54
|
+
mode: 'web',
|
|
55
|
+
advSecretKey: store.advSecretKey || null,
|
|
56
|
+
pairingCode: store.pairingCode || null,
|
|
57
|
+
pairingEphemeralKeyPair: store.pairingEphemeralKeyPair ? {
|
|
58
|
+
private: store.pairingEphemeralKeyPair.private.toString('base64'),
|
|
59
|
+
public: store.pairingEphemeralKeyPair.public.toString('base64')
|
|
60
|
+
} : null,
|
|
61
|
+
me: store.me || null,
|
|
62
|
+
deviceIndex: store.deviceIndex || 0,
|
|
63
|
+
platform: store.platform || null,
|
|
64
|
+
webVersion: store.webVersion || WEB_VERSION_FALLBACK.slice(),
|
|
65
|
+
browser: store.browser || WEB_BROWSER.slice(),
|
|
66
|
+
syncFullHistory: store.syncFullHistory !== false
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function webStoreFromJson(obj) {
|
|
71
|
+
const store = storeFromJson(obj);
|
|
72
|
+
|
|
73
|
+
store.mode = 'web';
|
|
74
|
+
store.advSecretKey = obj.advSecretKey || null;
|
|
75
|
+
store.pairingCode = obj.pairingCode || null;
|
|
76
|
+
store.pairingEphemeralKeyPair = obj.pairingEphemeralKeyPair
|
|
77
|
+
? {
|
|
78
|
+
private: Buffer.from(obj.pairingEphemeralKeyPair.private, 'base64'),
|
|
79
|
+
public: Buffer.from(obj.pairingEphemeralKeyPair.public, 'base64')
|
|
80
|
+
}
|
|
81
|
+
// A store written before this field existed still needs a usable key pair;
|
|
82
|
+
// it is only ever consumed inside a single pairing attempt.
|
|
83
|
+
: generateEphemeralKeyPair();
|
|
84
|
+
store.me = obj.me || null;
|
|
85
|
+
store.deviceIndex = obj.deviceIndex || 0;
|
|
86
|
+
store.platform = obj.platform || null;
|
|
87
|
+
store.webVersion = obj.webVersion || WEB_VERSION_FALLBACK.slice();
|
|
88
|
+
store.browser = obj.browser || WEB_BROWSER.slice();
|
|
89
|
+
store.syncFullHistory = obj.syncFullHistory !== false;
|
|
90
|
+
|
|
91
|
+
return store;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function saveWebStore(store, filePath) {
|
|
95
|
+
if (!store) return;
|
|
96
|
+
const dir = path.dirname(filePath);
|
|
97
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
98
|
+
fs.writeFileSync(filePath, JSON.stringify(webStoreToJson(store), null, 2), 'utf8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadWebStore(filePath) {
|
|
102
|
+
if (!fs.existsSync(filePath)) return null;
|
|
103
|
+
try {
|
|
104
|
+
return webStoreFromJson(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
createNewWebStore,
|
|
112
|
+
saveWebStore,
|
|
113
|
+
loadWebStore,
|
|
114
|
+
webStoreToJson,
|
|
115
|
+
webStoreFromJson,
|
|
116
|
+
webSessionPath
|
|
117
|
+
};
|
package/lib/constants.js
CHANGED
|
@@ -7,6 +7,26 @@ const REGISTRATION_PUBLIC_KEY = Buffer.from('8e8c0f74c3ebc5d7a6865c6c3c843856b06
|
|
|
7
7
|
|
|
8
8
|
const MOBILE_PROLOGUE = Buffer.from([0x57, 0x41, 0x05, 0x03]);
|
|
9
9
|
|
|
10
|
+
// ─── WhatsApp Web (companion) transport ──────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// The mobile client speaks the same Noise handshake and the same binary node
|
|
13
|
+
// dictionary over a raw TLS socket to g.whatsapp.net. The web client speaks it
|
|
14
|
+
// over a WebSocket to web.whatsapp.com. The only wire-level differences are the
|
|
15
|
+
// prologue byte that announces which edition is talking (5 = mobile,
|
|
16
|
+
// 6 = web/multi-device) and the ClientPayload that rides inside the handshake.
|
|
17
|
+
const WEB_PROLOGUE = Buffer.from([0x57, 0x41, 0x06, 0x03]);
|
|
18
|
+
const WEB_SOCKET_URL = 'wss://web.whatsapp.com/ws/chat';
|
|
19
|
+
const WEB_ORIGIN = 'https://web.whatsapp.com';
|
|
20
|
+
|
|
21
|
+
// Version the companion announces. Unlike the mobile version this is not tied
|
|
22
|
+
// to an App Store build — it is the web client release, and the server is
|
|
23
|
+
// tolerant of a slightly stale one.
|
|
24
|
+
const WEB_VERSION_FALLBACK = [2, 3000, 1027934701];
|
|
25
|
+
|
|
26
|
+
// [os, browser] pair. Decides the DeviceProps.platformType we register with and
|
|
27
|
+
// the name the account owner sees in the linked-devices list on their phone.
|
|
28
|
+
const WEB_BROWSER = ['Ubuntu', 'Chrome', '120.0.0.0'];
|
|
29
|
+
|
|
10
30
|
// Static tokens used in WhatsApp registration token computation.
|
|
11
31
|
// token = MD5( staticToken + MD5hex(waVersion) + nationalNumber )
|
|
12
32
|
const IOS_STATIC_TOKEN = '0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM';
|
|
@@ -197,6 +217,11 @@ module.exports = {
|
|
|
197
217
|
REGISTRATION_ENDPOINT,
|
|
198
218
|
REGISTRATION_PUBLIC_KEY,
|
|
199
219
|
MOBILE_PROLOGUE,
|
|
220
|
+
WEB_PROLOGUE,
|
|
221
|
+
WEB_SOCKET_URL,
|
|
222
|
+
WEB_ORIGIN,
|
|
223
|
+
WEB_VERSION_FALLBACK,
|
|
224
|
+
WEB_BROWSER,
|
|
200
225
|
IOS_STATIC_TOKEN,
|
|
201
226
|
IOS_BUSINESS_STATIC_TOKEN,
|
|
202
227
|
ANDROID_STATIC_TOKEN,
|
|
@@ -23,7 +23,8 @@ const {
|
|
|
23
23
|
} = require('../proto/MessageProto');
|
|
24
24
|
const { uploadMedia } = require('../MediaService');
|
|
25
25
|
const { imageThumbnail, videoThumbnail, probeImageSize } = require('../MediaThumbnail');
|
|
26
|
-
const { tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken
|
|
26
|
+
const { tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken,
|
|
27
|
+
isRegularTcTokenUser } = require('./TcTokenStore');
|
|
27
28
|
|
|
28
29
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -932,15 +933,19 @@ class MessageSender {
|
|
|
932
933
|
]);
|
|
933
934
|
otherJids = lidDevices.length > 0 ? lidDevices : [toJid];
|
|
934
935
|
ownLinkedJids = ownLidDevices;
|
|
935
|
-
ownPrimaryJid = `${ownLidUser}@lid`;
|
|
936
|
+
ownPrimaryJid = this._client._ownDeviceLidJid() || `${ownLidUser}@lid`;
|
|
936
937
|
} else {
|
|
937
938
|
const [pnDevices, ownDevices] = await Promise.all([
|
|
938
939
|
this._devMgr.bulkEnsureSessions([recipientPhone], this._signal, allowPkmsg),
|
|
939
940
|
this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal, allowPkmsg)
|
|
940
941
|
]);
|
|
941
942
|
otherJids = pnDevices.length > 0 ? pnDevices : [toJid];
|
|
942
|
-
|
|
943
|
-
|
|
943
|
+
// Every own device except the one doing the sending. As a primary that is
|
|
944
|
+
// device 0 and the list below drops it; as a companion the account's
|
|
945
|
+
// phone is device 0 and must stay in — it is a peer we encrypt to, not
|
|
946
|
+
// ourselves. The filter a few lines down removes whichever one we are.
|
|
947
|
+
ownLinkedJids = ownDevices;
|
|
948
|
+
ownPrimaryJid = this._client._ownDeviceJid();
|
|
944
949
|
}
|
|
945
950
|
|
|
946
951
|
// The device doing the sending is never one of its own recipients. Both
|
|
@@ -1014,7 +1019,10 @@ class MessageSender {
|
|
|
1014
1019
|
// event. Once enough accumulate the server returns error 463 (timelock).
|
|
1015
1020
|
// We attach the stored trusted-contact token when one exists and is valid.
|
|
1016
1021
|
const tcStore = this._client._tcTokenStore;
|
|
1017
|
-
|
|
1022
|
+
// Keyed by the contact's LID, which is where a token they issued us was
|
|
1023
|
+
// filed when it arrived. Looking it up by the phone JID — which is what
|
|
1024
|
+
// this did — never found one, so nothing was ever attached.
|
|
1025
|
+
const tcJid = this._client._resolveTcTokenJid(routingToJid);
|
|
1018
1026
|
if (tcStore) {
|
|
1019
1027
|
const tcEntry = tcStore.get(tcJid);
|
|
1020
1028
|
if (tcEntry && tcEntry.token && tcEntry.token.length) {
|
|
@@ -1078,11 +1086,23 @@ class MessageSender {
|
|
|
1078
1086
|
// for this JID (one issuance per 7-day bucket is enough). The token is
|
|
1079
1087
|
// stored and attached on future sends so the server does not count them
|
|
1080
1088
|
// as anonymous "reaching out" events (which would trigger error 463).
|
|
1081
|
-
|
|
1089
|
+
//
|
|
1090
|
+
// Two targets never get one. WhatsApp's own announcement account
|
|
1091
|
+
// (0@s.whatsapp.net) and the bot numbers, including Meta AI, are not
|
|
1092
|
+
// contacts you can be "timelocked" against, and no real client issues to
|
|
1093
|
+
// them — whatsmeow gates on shouldSendTCTokenInChatAction, Baileys on
|
|
1094
|
+
// isBotOrPSA. Protocol messages (a revoke, an ephemeral-timer change) are
|
|
1095
|
+
// out too: Baileys skips issuance for them because they are bookkeeping,
|
|
1096
|
+
// not a reach-out. Attaching an existing token to them is still correct
|
|
1097
|
+
// and still happens above — this only governs asking for a new one.
|
|
1098
|
+
const isProtocolMsg = !!(options && options._protocol);
|
|
1099
|
+
if (tcStore && isRegularTcTokenUser(tcJid) && !isProtocolMsg &&
|
|
1100
|
+
tcStore.shouldIssue(tcJid) &&
|
|
1082
1101
|
!this._client._inFlightTcTokenIssuance.has(tcJid)) {
|
|
1083
1102
|
this._client._inFlightTcTokenIssuance.add(tcJid);
|
|
1084
|
-
const issueTs
|
|
1085
|
-
this._client.
|
|
1103
|
+
const issueTs = Math.floor(Date.now() / 1000);
|
|
1104
|
+
const issueJid = this._client._resolveTcTokenIssuanceJid(routingToJid);
|
|
1105
|
+
this._client._issuePrivacyTokens(issueJid, issueTs)
|
|
1086
1106
|
.then(result => this._client._storeTcTokenFromIqResult(result, tcJid, issueTs))
|
|
1087
1107
|
.catch(() => {})
|
|
1088
1108
|
.finally(() => this._client._inFlightTcTokenIssuance.delete(tcJid));
|
|
@@ -1110,10 +1130,17 @@ class MessageSender {
|
|
|
1110
1130
|
(typeof this._client._getStatusRecipients === 'function'
|
|
1111
1131
|
? await this._client._getStatusRecipients()
|
|
1112
1132
|
: []);
|
|
1113
|
-
|
|
1133
|
+
// Our own JID is always in the list so the post reaches our own devices,
|
|
1134
|
+
// so a list of length one means there is nobody else on it. Counting the
|
|
1135
|
+
// raw list let that through: the walk below then dropped us as the
|
|
1136
|
+
// sender, left no targets, and the stanza went out as a bare skmsg with
|
|
1137
|
+
// no <participants> — a SenderKey message nobody holds the key for, which
|
|
1138
|
+
// the server answers with ack 479.
|
|
1139
|
+
const others = members.filter(jid => !this._client._isOwnUser(jid));
|
|
1140
|
+
if (others.length === 0) {
|
|
1114
1141
|
throw new Error(
|
|
1115
|
-
'Cannot post a Status:
|
|
1116
|
-
'
|
|
1142
|
+
'Cannot post a Status: it would reach nobody. The contact list is ' +
|
|
1143
|
+
'empty — sync history first, or pass { recipients: [...] }.'
|
|
1117
1144
|
);
|
|
1118
1145
|
}
|
|
1119
1146
|
}
|
|
@@ -1170,7 +1197,13 @@ class MessageSender {
|
|
|
1170
1197
|
// For LID-mode groups, sender identity is own LID JID; otherwise own PN JID.
|
|
1171
1198
|
// Normalised to the bare user so it matches the participant targets below.
|
|
1172
1199
|
const selfJid = useLid ? `${myLidUser}@lid` : ownJid;
|
|
1173
|
-
|
|
1200
|
+
// The SenderKey is this device's, so the identity it is filed under carries
|
|
1201
|
+
// our device index. On a primary that index is 0 and this is exactly the
|
|
1202
|
+
// bare JID above; on a companion it keeps our key distinct from the
|
|
1203
|
+
// account's phone, which distributes its own.
|
|
1204
|
+
const senderIdentity = (useLid
|
|
1205
|
+
? this._client._ownDeviceLidJid()
|
|
1206
|
+
: this._client._ownDeviceJid()) || selfJid;
|
|
1174
1207
|
|
|
1175
1208
|
const rawSKDM = await this._signal.buildSKDM(groupJid, senderIdentity);
|
|
1176
1209
|
const skdmMsg = encodeSenderKeyDistributionMessage(groupJid, rawSKDM);
|
|
@@ -1254,10 +1287,26 @@ class MessageSender {
|
|
|
1254
1287
|
|
|
1255
1288
|
// Targets to actually encrypt for: everything but the sending device. Both
|
|
1256
1289
|
// Baileys and whatsmeow skip it explicitly at this point and not before.
|
|
1257
|
-
|
|
1258
|
-
|
|
1290
|
+
// Only the device actually sending. On a companion link that is not the
|
|
1291
|
+
// account's phone — the phone is a peer that has to receive the SenderKey
|
|
1292
|
+
// like everyone else, so excluding the bare number here would leave the
|
|
1293
|
+
// owner's own phone unable to read the message.
|
|
1294
|
+
const selfAddrs = new Set([adString(this._client._ownDeviceJid())]);
|
|
1295
|
+
const ownLidSelf = this._client._ownDeviceLidJid();
|
|
1296
|
+
if (ownLidSelf) selfAddrs.add(adString(ownLidSelf));
|
|
1297
|
+
else if (myLidUser) selfAddrs.add(adString(`${myLidUser}@lid`));
|
|
1259
1298
|
const allTargets = enumeratedDevices.filter(jid => !selfAddrs.has(adString(jid)));
|
|
1260
1299
|
|
|
1300
|
+
// Whatever the member list said, if nothing came back with a session there
|
|
1301
|
+
// is no one to hand the SenderKey to and the skmsg below is undeliverable.
|
|
1302
|
+
// Better to say so than to emit it and read the 479 back.
|
|
1303
|
+
if (isStatus && allTargets.length === 0) {
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
'Cannot post a Status: no Signal session could be opened with any ' +
|
|
1306
|
+
'recipient, so the post would reach nobody.'
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1261
1310
|
const skStore = this._signal.senderKeyStore;
|
|
1262
1311
|
const existingSkdmMap = skStore.getSKDMMap(groupJid);
|
|
1263
1312
|
// Same exclusions Baileys applies when choosing SenderKey recipients:
|
|
@@ -1411,7 +1460,7 @@ class MessageSender {
|
|
|
1411
1460
|
// concurrent 463 recoveries and proactive issuances don't clash.
|
|
1412
1461
|
if (error === '463' && !_isRetry) {
|
|
1413
1462
|
const ackFrom = ackAttrs.from ? String(ackAttrs.from) : '';
|
|
1414
|
-
const tcJid =
|
|
1463
|
+
const tcJid = client._resolveTcTokenJid(
|
|
1415
1464
|
ackFrom || String(node.attrs && node.attrs.to || '')
|
|
1416
1465
|
);
|
|
1417
1466
|
_whaDbg('[DBG] ACK_463 from=' + ackFrom + ' id=' + id +
|
|
@@ -1439,7 +1488,13 @@ class MessageSender {
|
|
|
1439
1488
|
client._inFlight463Recoveries.add(tcJid);
|
|
1440
1489
|
const issueTs = Math.floor(Date.now() / 1000);
|
|
1441
1490
|
|
|
1442
|
-
|
|
1491
|
+
// Same address family as every other issuance path — see
|
|
1492
|
+
// Client._resolveTcTokenIssuanceJid. This used to hand the server
|
|
1493
|
+
// whatever the ack happened to carry.
|
|
1494
|
+
const issueJid = client._resolveTcTokenIssuanceJid(
|
|
1495
|
+
ackFrom || String(node.attrs && node.attrs.to || '')
|
|
1496
|
+
);
|
|
1497
|
+
client._issuePrivacyTokens(issueJid, issueTs)
|
|
1443
1498
|
.then(result => client._storeTcTokenFromIqResult(result, tcJid, issueTs))
|
|
1444
1499
|
.then(() => {
|
|
1445
1500
|
// Verify that real token bytes were received from the server.
|
|
@@ -1540,7 +1595,9 @@ class MessageSender {
|
|
|
1540
1595
|
const msgBuf = encodeMessage('protocol', revokePayload);
|
|
1541
1596
|
// ProtocolMessage is one of the explicit "text" cases in whatsmeow's
|
|
1542
1597
|
// getTypeFromMessage; there is no "protocol" stanza type.
|
|
1543
|
-
|
|
1598
|
+
// _protocol marks it for the tcToken issuance gate — see _sendDMMessage.
|
|
1599
|
+
return this._sendMessage(toJid, msgId, msgBuf, 'text',
|
|
1600
|
+
{ ...opts, edit: editBit, _protocol: true });
|
|
1544
1601
|
}
|
|
1545
1602
|
|
|
1546
1603
|
// ─── Ephemeral timer DM ───────────────────────────────────────────────────
|
|
@@ -1555,7 +1612,7 @@ class MessageSender {
|
|
|
1555
1612
|
ephemeralExpiration: seconds
|
|
1556
1613
|
});
|
|
1557
1614
|
const msgBuf = encodeMessage('protocol', payload);
|
|
1558
|
-
return this._sendMessage(toJid, msgId, msgBuf, 'text', opts);
|
|
1615
|
+
return this._sendMessage(toJid, msgId, msgBuf, 'text', { ...opts, _protocol: true });
|
|
1559
1616
|
}
|
|
1560
1617
|
|
|
1561
1618
|
// ─── Utility: mark read, presence, receipt, ping ─────────────────────────
|
|
@@ -1610,7 +1667,10 @@ class MessageSender {
|
|
|
1610
1667
|
await this._fetchMediaConn();
|
|
1611
1668
|
return { hosts: this._mediaHosts, auth: this._mediaAuth };
|
|
1612
1669
|
};
|
|
1613
|
-
|
|
1670
|
+
// A companion talks to the CDN as a browser: different endpoint for the
|
|
1671
|
+
// types the web client folds together, and an Origin header.
|
|
1672
|
+
return uploadMedia(mediaType, buf, this._mediaHosts, this._mediaAuth, refreshAuth,
|
|
1673
|
+
{ web: this._client && this._client._mode === 'web' });
|
|
1614
1674
|
}
|
|
1615
1675
|
|
|
1616
1676
|
async _fetchMediaConn() {
|
|
@@ -6,6 +6,14 @@ const path = require('path');
|
|
|
6
6
|
// 7-day buckets, 4 buckets = ~28-day rolling window (matches Baileys tc-token-utils)
|
|
7
7
|
const TC_BUCKET_DURATION = 604800;
|
|
8
8
|
const TC_NUM_BUCKETS = 4;
|
|
9
|
+
// whatsmeow prunes expired rows out of the token table at most once a day
|
|
10
|
+
// (tcTokenDBPruneInterval). Ours is a JSON file, so the same cadence applies.
|
|
11
|
+
const TC_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
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
|
+
const BOT_USER_RE = /^1313555\d{4}$|^131655500\d{2}$/;
|
|
9
17
|
|
|
10
18
|
function tcTokenExpired(timestamp) {
|
|
11
19
|
if (timestamp == null) return true;
|
|
@@ -25,6 +33,26 @@ function shouldSendNewTcToken(senderTimestamp) {
|
|
|
25
33
|
return curBucket > senderBucket;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
// Whether a JID is a real person we may hold a trusted-contact token for.
|
|
37
|
+
//
|
|
38
|
+
// whatsmeow gates issuance with shouldSendTCTokenInChatAction and Baileys gates
|
|
39
|
+
// both issuance and storage with isRegularUser; the two agree on the rule:
|
|
40
|
+
// a user address (phone or LID), not the PSA pseudo-contact 0@…, not a bot.
|
|
41
|
+
// Without it we issue a privacy IQ to WhatsApp's own announcement account and
|
|
42
|
+
// to Meta AI, which no real client ever does.
|
|
43
|
+
function isRegularTcTokenUser(jid) {
|
|
44
|
+
if (!jid) return false;
|
|
45
|
+
const norm = normalizeJidForTcToken(jid);
|
|
46
|
+
const atIdx = norm.indexOf('@');
|
|
47
|
+
if (atIdx < 0) return false;
|
|
48
|
+
const user = norm.slice(0, atIdx);
|
|
49
|
+
const server = norm.slice(atIdx + 1);
|
|
50
|
+
if (server !== 's.whatsapp.net' && server !== 'c.us' && server !== 'lid') return false;
|
|
51
|
+
if (user === '0') return false; // PSA
|
|
52
|
+
if (BOT_USER_RE.test(user)) return false; // bots, Meta AI
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
// Strip device part and normalize to bare JID
|
|
29
57
|
function normalizeJidForTcToken(jid) {
|
|
30
58
|
if (!jid) return String(jid || '');
|
|
@@ -43,7 +71,36 @@ class TcTokenStore {
|
|
|
43
71
|
this._path = filePath;
|
|
44
72
|
// jid → { token: Buffer|null, timestamp: number|null, senderTimestamp: number|null }
|
|
45
73
|
this._map = new Map();
|
|
74
|
+
this._lastPrune = 0;
|
|
46
75
|
this._load();
|
|
76
|
+
this.pruneExpired();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Drop entries that are past the 28-day window on both counts.
|
|
80
|
+
//
|
|
81
|
+
// whatsmeow runs DeleteExpiredPrivacyTokens against the same cutoff and lets
|
|
82
|
+
// its in-memory sender-timestamp map age out on the same boundary. An entry
|
|
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
|
|
85
|
+
// every single send.
|
|
86
|
+
pruneExpired() {
|
|
87
|
+
let removed = 0;
|
|
88
|
+
for (const [jid, entry] of this._map) {
|
|
89
|
+
const tokenDead = !entry.token || !entry.token.length || tcTokenExpired(entry.timestamp);
|
|
90
|
+
const senderDead = tcTokenExpired(entry.senderTimestamp);
|
|
91
|
+
if (tokenDead && senderDead) {
|
|
92
|
+
this._map.delete(jid);
|
|
93
|
+
removed++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this._lastPrune = Date.now();
|
|
97
|
+
if (removed) this._save();
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_maybePrune() {
|
|
102
|
+
if (Date.now() - this._lastPrune < TC_PRUNE_INTERVAL_MS) return;
|
|
103
|
+
this.pruneExpired();
|
|
47
104
|
}
|
|
48
105
|
|
|
49
106
|
_load() {
|
|
@@ -77,14 +134,36 @@ class TcTokenStore {
|
|
|
77
134
|
}
|
|
78
135
|
|
|
79
136
|
get(jid) {
|
|
137
|
+
// whatsmeow prunes from ensureTCToken, i.e. on the read path, rate-limited
|
|
138
|
+
// to once a day. Same here.
|
|
139
|
+
this._maybePrune();
|
|
80
140
|
return this._map.get(normalizeJidForTcToken(jid)) || null;
|
|
81
141
|
}
|
|
82
142
|
|
|
143
|
+
// Returns true when the token was accepted.
|
|
144
|
+
//
|
|
145
|
+
// Two rejections, both taken from Baileys' storeTcTokensFromIqResult:
|
|
146
|
+
// - a token with no timestamp is already expired by definition, so storing
|
|
147
|
+
// it only wastes a slot and guarantees a miss on the next send;
|
|
148
|
+
// - a token older than the one on file must never overwrite it. Token
|
|
149
|
+
// deliveries are not ordered — an issuance result and a privacy_token
|
|
150
|
+
// notification for the same contact can land in either order — so without
|
|
151
|
+
// this a stale copy can replace a fresh one.
|
|
83
152
|
setToken(jid, tokenBuf, timestamp) {
|
|
84
153
|
const key = normalizeJidForTcToken(jid);
|
|
85
|
-
const
|
|
86
|
-
|
|
154
|
+
const incoming = timestamp != null ? Number(timestamp) : 0;
|
|
155
|
+
if (!incoming || isNaN(incoming)) return false;
|
|
156
|
+
|
|
157
|
+
const existing = this._map.get(key) || {};
|
|
158
|
+
const existingTs = existing.timestamp != null ? Number(existing.timestamp) : 0;
|
|
159
|
+
if (existingTs > 0 && existingTs > incoming &&
|
|
160
|
+
existing.token && existing.token.length) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this._map.set(key, { ...existing, token: tokenBuf, timestamp: incoming });
|
|
87
165
|
this._save();
|
|
166
|
+
return true;
|
|
88
167
|
}
|
|
89
168
|
|
|
90
169
|
clearToken(jid) {
|
|
@@ -113,4 +192,12 @@ class TcTokenStore {
|
|
|
113
192
|
}
|
|
114
193
|
}
|
|
115
194
|
|
|
116
|
-
module.exports = {
|
|
195
|
+
module.exports = {
|
|
196
|
+
TcTokenStore,
|
|
197
|
+
tcTokenExpired,
|
|
198
|
+
shouldSendNewTcToken,
|
|
199
|
+
normalizeJidForTcToken,
|
|
200
|
+
isRegularTcTokenUser,
|
|
201
|
+
TC_BUCKET_DURATION,
|
|
202
|
+
TC_NUM_BUCKETS
|
|
203
|
+
};
|
package/lib/noise.js
CHANGED
|
@@ -6,7 +6,7 @@ const EventEmitter = require('events');
|
|
|
6
6
|
const curveJs = require('curve25519-js');
|
|
7
7
|
const { sha256 } = require('@noble/hashes/sha256');
|
|
8
8
|
const { hkdf } = require('@noble/hashes/hkdf');
|
|
9
|
-
const { MOBILE_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants');
|
|
9
|
+
const { MOBILE_PROLOGUE, WEB_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants');
|
|
10
10
|
const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
|
|
11
11
|
decodeServerHello, encodeClientPayload } = require('./proto');
|
|
12
12
|
const { parsePhone, getCountryMeta } = require('./Registration');
|
|
@@ -112,7 +112,10 @@ function deriveHkdf(salt, ikm, length) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
class NoiseState {
|
|
115
|
-
|
|
115
|
+
// The prologue is mixed into the handshake hash, so mobile and web produce
|
|
116
|
+
// different transcripts from the first byte and a payload built for one
|
|
117
|
+
// edition can never authenticate against the other.
|
|
118
|
+
constructor(prologue) {
|
|
116
119
|
// Noise spec: if protocol_name.length <= HASHLEN(32), h = protocol_name padded with zeros.
|
|
117
120
|
// Do NOT hash — use the raw 32-byte name directly as the initial h and ck.
|
|
118
121
|
const modeBytes = Buffer.from(NOISE_MODE, 'utf8'); // 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' = 32 bytes
|
|
@@ -120,7 +123,7 @@ class NoiseState {
|
|
|
120
123
|
this.ck = Buffer.from(modeBytes);
|
|
121
124
|
this.key = null; // single key for both enc+dec during handshake
|
|
122
125
|
this.counter = 0; // single counter for both encrypt+decrypt
|
|
123
|
-
this.hash(MOBILE_PROLOGUE);
|
|
126
|
+
this.hash(prologue || MOBILE_PROLOGUE);
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
hash(data) {
|
|
@@ -188,8 +191,26 @@ function readFrameLength(data) {
|
|
|
188
191
|
// 'error'(err) — an error occurred
|
|
189
192
|
//
|
|
190
193
|
class NoiseSocket extends EventEmitter {
|
|
191
|
-
|
|
194
|
+
/**
|
|
195
|
+
* @param {object} store session store
|
|
196
|
+
* @param {object} [opts]
|
|
197
|
+
* transport 'mobile' (raw TLS to g.whatsapp.net) or 'web' (WebSocket
|
|
198
|
+
* to web.whatsapp.com). Defaults to mobile — every existing
|
|
199
|
+
* caller keeps its behaviour without passing anything.
|
|
200
|
+
* buildPayload () => Buffer, replaces the mobile ClientPayload. Web mode
|
|
201
|
+
* supplies the companion register/login payload here.
|
|
202
|
+
* expectSuccess when false the connect() promise resolves as soon as the
|
|
203
|
+
* channel is secured instead of waiting for <success>. A
|
|
204
|
+
* companion that has not been paired yet never receives one:
|
|
205
|
+
* the server replies with pair-device and waits for a human.
|
|
206
|
+
*/
|
|
207
|
+
constructor(store, opts) {
|
|
192
208
|
super();
|
|
209
|
+
opts = opts || {};
|
|
210
|
+
this.transport = opts.transport === 'web' ? 'web' : 'mobile';
|
|
211
|
+
this._buildPayload = opts.buildPayload || null;
|
|
212
|
+
this._expectSuccess = opts.expectSuccess !== false;
|
|
213
|
+
this._prologue = this.transport === 'web' ? WEB_PROLOGUE : MOBILE_PROLOGUE;
|
|
193
214
|
this.store = store;
|
|
194
215
|
this.socket = null;
|
|
195
216
|
this.noiseState = null;
|
|
@@ -211,7 +232,12 @@ class NoiseSocket extends EventEmitter {
|
|
|
211
232
|
this._connectResolve = resolve;
|
|
212
233
|
this._connectReject = reject;
|
|
213
234
|
|
|
214
|
-
this.
|
|
235
|
+
if (this.transport === 'web') {
|
|
236
|
+
const { WebSocketStream } = require('./WebSocketStream');
|
|
237
|
+
this.socket = new WebSocketStream().connect();
|
|
238
|
+
} else {
|
|
239
|
+
this.socket = net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT });
|
|
240
|
+
}
|
|
215
241
|
// OS-level TCP keepalive: detects silently-dead NAT connections that never send FIN/RST
|
|
216
242
|
this.socket.setKeepAlive(true, 15000);
|
|
217
243
|
// Connect-phase watchdog: abort if TCP handshake takes >20s
|
|
@@ -241,7 +267,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
241
267
|
}
|
|
242
268
|
|
|
243
269
|
_startHandshake() {
|
|
244
|
-
this.noiseState = new NoiseState();
|
|
270
|
+
this.noiseState = new NoiseState(this._prologue);
|
|
245
271
|
const seed = crypto.randomBytes(32);
|
|
246
272
|
const kp = curveJs.generateKeyPair(seed);
|
|
247
273
|
this._ephemeralKeyPair = {
|
|
@@ -251,7 +277,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
251
277
|
this.noiseState.hash(this._ephemeralKeyPair.public);
|
|
252
278
|
|
|
253
279
|
const helloPayload = encodeHandshakeClientHello(this._ephemeralKeyPair.public);
|
|
254
|
-
const frame = Buffer.concat([
|
|
280
|
+
const frame = Buffer.concat([this._prologue, makeFrame(helloPayload)]);
|
|
255
281
|
this.socket.write(frame);
|
|
256
282
|
}
|
|
257
283
|
|
|
@@ -302,6 +328,37 @@ class NoiseSocket extends EventEmitter {
|
|
|
302
328
|
const sharedSS = dhShared(noisePriv, serverHello.ephemeral);
|
|
303
329
|
this.noiseState.mixKey(sharedSS);
|
|
304
330
|
|
|
331
|
+
// Web mode hands us a finished companion payload; there is no phone
|
|
332
|
+
// identity to derive anything from, and the fields below do not exist on
|
|
333
|
+
// that side of the protocol.
|
|
334
|
+
if (this._buildPayload) {
|
|
335
|
+
const encPayload = this.noiseState.encryptWithAd(this._buildPayload());
|
|
336
|
+
const finish = encodeHandshakeClientFinish(encStatic, encPayload);
|
|
337
|
+
this.socket.write(makeFrame(finish));
|
|
338
|
+
|
|
339
|
+
const webKeys = this.noiseState.split();
|
|
340
|
+
this.writeKey = webKeys.writeKey;
|
|
341
|
+
this.readKey = webKeys.readKey;
|
|
342
|
+
this.writeCounter = 0;
|
|
343
|
+
this.readCounter = 0;
|
|
344
|
+
this.secured = true;
|
|
345
|
+
|
|
346
|
+
if (this._expectSuccess) {
|
|
347
|
+
this._awaitingAuth = true;
|
|
348
|
+
} else {
|
|
349
|
+
// An unpaired companion gets pair-device, not <success>. Hand the
|
|
350
|
+
// caller a live channel and let the pairing flow drive from here.
|
|
351
|
+
this._awaitingAuth = false;
|
|
352
|
+
if (this._connectResolve) {
|
|
353
|
+
this._connectResolve();
|
|
354
|
+
this._connectResolve = null;
|
|
355
|
+
this._connectReject = null;
|
|
356
|
+
}
|
|
357
|
+
this.emit('secured');
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
305
362
|
// Auto-derive MCC/MNC and locale from the registered phone number so the
|
|
306
363
|
// ClientPayload userAgent is never sent with the telltale '000'/'000' values.
|
|
307
364
|
const _phoneMeta = getCountryMeta(parsePhone(this.store.phoneNumber).cc);
|
|
@@ -411,6 +468,14 @@ class NoiseSocket extends EventEmitter {
|
|
|
411
468
|
}
|
|
412
469
|
// ─────────────────────────────────────────────────────────────────────
|
|
413
470
|
|
|
471
|
+
// A companion that connected unpaired resolves connect() early, so
|
|
472
|
+
// <success> arrives here as an ordinary node once the link completes.
|
|
473
|
+
// It still means the same thing, so it still opens the session.
|
|
474
|
+
if (!this._expectSuccess && node && node.description === 'success') {
|
|
475
|
+
this.emit('open', node);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
414
479
|
this.emit('node', node);
|
|
415
480
|
} catch (err) {
|
|
416
481
|
this.emit('error', err);
|
|
@@ -282,11 +282,20 @@ class SignalProtocol {
|
|
|
282
282
|
// SenderKeyName format: groupId::senderId::0 (device 0 for group identity).
|
|
283
283
|
|
|
284
284
|
_senderKeyName(groupId, senderId) {
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
// A SenderKey belongs to a device, not to an account. Every device that
|
|
286
|
+
// posts to a group distributes its own key, so the name has to carry the
|
|
287
|
+
// device index the way Baileys and whatsmeow do — otherwise two devices of
|
|
288
|
+
// the same person share one slot and the second one to speak overwrites
|
|
289
|
+
// the first, leaving its messages undecryptable.
|
|
290
|
+
//
|
|
291
|
+
// Bare JIDs still resolve to device 0, so everything already on disk keeps
|
|
292
|
+
// the exact same key string.
|
|
293
|
+
const raw = typeof senderId === 'string' ? senderId : String(senderId);
|
|
294
|
+
const userPart = raw.split('@')[0];
|
|
295
|
+
const colon = userPart.indexOf(':');
|
|
296
|
+
const user = colon >= 0 ? userPart.slice(0, colon) : userPart;
|
|
297
|
+
const deviceId = colon >= 0 ? (parseInt(userPart.slice(colon + 1), 10) || 0) : 0;
|
|
298
|
+
return new SenderKeyName(groupId, { id: user, deviceId });
|
|
290
299
|
}
|
|
291
300
|
|
|
292
301
|
async senderKeyEncrypt(groupId, senderId, plaintext) {
|