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.
@@ -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 } = require('./TcTokenStore');
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
- ownLinkedJids = ownDevices.filter(j => j !== ownMainJid);
943
- ownPrimaryJid = ownMainJid;
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
- const tcJid = normalizeJidForTcToken(routingToJid);
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
- if (tcStore && tcStore.shouldIssue(tcJid) &&
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 = Math.floor(Date.now() / 1000);
1085
- this._client._issuePrivacyTokens(routingToJid, issueTs)
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
- if (!members.length) {
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: no recipients. Sync history so the contact ' +
1116
- 'list is known, or pass { recipients: [...] }.'
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
- const senderIdentity = selfJid;
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
- const selfAddrs = new Set([adString(`${ownPhone}@s.whatsapp.net`)]);
1258
- if (myLidUser) selfAddrs.add(adString(`${myLidUser}@lid`));
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 = normalizeJidForTcToken(
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
- client._issuePrivacyTokens(ackFrom || tcJid, issueTs)
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
- return this._sendMessage(toJid, msgId, msgBuf, 'text', { ...opts, edit: editBit });
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
- return uploadMedia(mediaType, buf, this._mediaHosts, this._mediaAuth, refreshAuth);
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 existing = this._map.get(key) || {};
86
- this._map.set(key, { ...existing, token: tokenBuf, timestamp });
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 = { TcTokenStore, tcTokenExpired, shouldSendNewTcToken, normalizeJidForTcToken };
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
- constructor() {
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
- constructor(store) {
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.socket = net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT });
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([MOBILE_PROLOGUE, makeFrame(helloPayload)]);
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
- // Strip device suffix (e.g. "1234567890:2@lid" → "1234567890")
286
- const user = typeof senderId === 'string'
287
- ? senderId.split('@')[0].split(':')[0]
288
- : String(senderId);
289
- return new SenderKeyName(groupId, { id: user, deviceId: 0 });
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) {