whalibmob 5.5.93 → 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 +459 -2
- package/cli.js +151 -5
- package/index.js +28 -0
- package/lib/Client.js +480 -11
- 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 +26 -7
- 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,
|
|
@@ -933,15 +933,19 @@ class MessageSender {
|
|
|
933
933
|
]);
|
|
934
934
|
otherJids = lidDevices.length > 0 ? lidDevices : [toJid];
|
|
935
935
|
ownLinkedJids = ownLidDevices;
|
|
936
|
-
ownPrimaryJid = `${ownLidUser}@lid`;
|
|
936
|
+
ownPrimaryJid = this._client._ownDeviceLidJid() || `${ownLidUser}@lid`;
|
|
937
937
|
} else {
|
|
938
938
|
const [pnDevices, ownDevices] = await Promise.all([
|
|
939
939
|
this._devMgr.bulkEnsureSessions([recipientPhone], this._signal, allowPkmsg),
|
|
940
940
|
this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal, allowPkmsg)
|
|
941
941
|
]);
|
|
942
942
|
otherJids = pnDevices.length > 0 ? pnDevices : [toJid];
|
|
943
|
-
|
|
944
|
-
|
|
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();
|
|
945
949
|
}
|
|
946
950
|
|
|
947
951
|
// The device doing the sending is never one of its own recipients. Both
|
|
@@ -1193,7 +1197,13 @@ class MessageSender {
|
|
|
1193
1197
|
// For LID-mode groups, sender identity is own LID JID; otherwise own PN JID.
|
|
1194
1198
|
// Normalised to the bare user so it matches the participant targets below.
|
|
1195
1199
|
const selfJid = useLid ? `${myLidUser}@lid` : ownJid;
|
|
1196
|
-
|
|
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;
|
|
1197
1207
|
|
|
1198
1208
|
const rawSKDM = await this._signal.buildSKDM(groupJid, senderIdentity);
|
|
1199
1209
|
const skdmMsg = encodeSenderKeyDistributionMessage(groupJid, rawSKDM);
|
|
@@ -1277,8 +1287,14 @@ class MessageSender {
|
|
|
1277
1287
|
|
|
1278
1288
|
// Targets to actually encrypt for: everything but the sending device. Both
|
|
1279
1289
|
// Baileys and whatsmeow skip it explicitly at this point and not before.
|
|
1280
|
-
|
|
1281
|
-
|
|
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`));
|
|
1282
1298
|
const allTargets = enumeratedDevices.filter(jid => !selfAddrs.has(adString(jid)));
|
|
1283
1299
|
|
|
1284
1300
|
// Whatever the member list said, if nothing came back with a session there
|
|
@@ -1651,7 +1667,10 @@ class MessageSender {
|
|
|
1651
1667
|
await this._fetchMediaConn();
|
|
1652
1668
|
return { hosts: this._mediaHosts, auth: this._mediaAuth };
|
|
1653
1669
|
};
|
|
1654
|
-
|
|
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' });
|
|
1655
1674
|
}
|
|
1656
1675
|
|
|
1657
1676
|
async _fetchMediaConn() {
|
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) {
|
package/lib/webproto.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ClientPayload encoders for the WhatsApp Web (companion) handshake.
|
|
4
|
+
//
|
|
5
|
+
// The mobile handshake in proto.js builds a payload around `username` and a
|
|
6
|
+
// phone-shaped UserAgent. A companion sends the same message type with a
|
|
7
|
+
// different set of fields: on its very first connection it carries
|
|
8
|
+
// devicePairingData — the registration bundle the account's primary device
|
|
9
|
+
// signs during pairing — and on every connection after that it carries the
|
|
10
|
+
// device index the server issued and asks to be caught up.
|
|
11
|
+
//
|
|
12
|
+
// Written by hand against the wire format for the same reason proto.js is:
|
|
13
|
+
// keeping the handshake independent of a protobuf runtime.
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// ─── primitives ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function encodeVarint(n) {
|
|
20
|
+
const out = [];
|
|
21
|
+
let v = n >>> 0;
|
|
22
|
+
do {
|
|
23
|
+
let b = v & 0x7f;
|
|
24
|
+
v >>>= 7;
|
|
25
|
+
if (v) b |= 0x80;
|
|
26
|
+
out.push(b);
|
|
27
|
+
} while (v);
|
|
28
|
+
return Buffer.from(out);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// username is a uint64 — the 32-bit helper above truncates long international
|
|
32
|
+
// numbers, so it gets its own encoder.
|
|
33
|
+
function encodeVarint64(value) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let v = BigInt(value);
|
|
36
|
+
do {
|
|
37
|
+
let b = Number(v & 0x7fn);
|
|
38
|
+
v >>= 7n;
|
|
39
|
+
if (v > 0n) b |= 0x80;
|
|
40
|
+
out.push(b);
|
|
41
|
+
} while (v > 0n);
|
|
42
|
+
return Buffer.from(out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function field(num, wireType, valueBytes) {
|
|
46
|
+
return Buffer.concat([encodeVarint((num << 3) | wireType), valueBytes]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bytesField(num, buf) { return field(num, 2, Buffer.concat([encodeVarint(buf.length), buf])); }
|
|
50
|
+
function stringField(num, str) { return bytesField(num, Buffer.from(String(str), 'utf8')); }
|
|
51
|
+
function varintField(num, n) { return field(num, 0, encodeVarint(n)); }
|
|
52
|
+
function uint64Field(num, n) { return field(num, 0, encodeVarint64(n)); }
|
|
53
|
+
function boolField(num, v) { return varintField(num, v ? 1 : 0); }
|
|
54
|
+
|
|
55
|
+
// WhatsApp encodes registration ids and pre-key ids inside the pairing bundle
|
|
56
|
+
// as fixed-width big-endian byte strings, not as varints.
|
|
57
|
+
function encodeBigEndian(value, length) {
|
|
58
|
+
length = length || 4;
|
|
59
|
+
const buf = Buffer.alloc(length);
|
|
60
|
+
for (let i = length - 1; i >= 0; i--) {
|
|
61
|
+
buf[i] = value & 0xff;
|
|
62
|
+
value = Math.floor(value / 256);
|
|
63
|
+
}
|
|
64
|
+
return buf;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stripKeyPrefix(pub) {
|
|
68
|
+
if (pub && pub.length === 33 && pub[0] === 0x05) return pub.slice(1);
|
|
69
|
+
return pub;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── DeviceProps ─────────────────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// How the companion describes itself to the primary. `os` and the platform type
|
|
75
|
+
// are what the account owner reads in the linked-devices list on their phone;
|
|
76
|
+
// historySyncConfig decides how much of the past the primary ships over once
|
|
77
|
+
// the link is established.
|
|
78
|
+
|
|
79
|
+
const PLATFORM_TYPE = {
|
|
80
|
+
UNKNOWN: 0, CHROME: 1, FIREFOX: 2, IE: 3, OPERA: 4, SAFARI: 5, EDGE: 6,
|
|
81
|
+
DESKTOP: 7, IPAD: 8, ANDROID_TABLET: 9, OHANA: 10, ALOHA: 11, CATALINA: 12,
|
|
82
|
+
TCL_TV: 13, IOS_PHONE: 14, IOS_CATALYST: 15, ANDROID_PHONE: 16,
|
|
83
|
+
ANDROID_AMBIGUOUS: 17, WEAR_OS: 18, AR_WRIST: 19, AR_DEVICE: 20, UWP: 21,
|
|
84
|
+
VR: 22, CLOUD_API: 23, SMARTGLASSES: 24
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function platformTypeFor(browserName) {
|
|
88
|
+
const key = String(browserName || '').toUpperCase();
|
|
89
|
+
if (key === 'ANDROID') return PLATFORM_TYPE.ANDROID_PHONE;
|
|
90
|
+
return PLATFORM_TYPE[key] !== undefined ? PLATFORM_TYPE[key] : PLATFORM_TYPE.CHROME;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// DeviceProps.HistorySyncConfig
|
|
94
|
+
function encodeHistorySyncConfig(opts) {
|
|
95
|
+
opts = opts || {};
|
|
96
|
+
const parts = [
|
|
97
|
+
varintField(3, opts.storageQuotaMb != null ? opts.storageQuotaMb : 10240),
|
|
98
|
+
boolField(4, opts.inlineInitialPayloadInE2EeMsg !== false),
|
|
99
|
+
boolField(6, !!opts.supportCallLogHistory),
|
|
100
|
+
boolField(7, opts.supportBotUserAgentChatHistory !== false),
|
|
101
|
+
boolField(8, opts.supportCagReactionsAndPolls !== false),
|
|
102
|
+
boolField(9, opts.supportBizHostedMsg !== false),
|
|
103
|
+
boolField(10, opts.supportRecentSyncChunkMessageCountTuning !== false),
|
|
104
|
+
boolField(11, opts.supportHostedGroupMsg !== false),
|
|
105
|
+
boolField(12, opts.supportFbidBotChatHistory !== false),
|
|
106
|
+
boolField(14, opts.supportMessageAssociation !== false),
|
|
107
|
+
boolField(15, !!opts.supportGroupHistory)
|
|
108
|
+
];
|
|
109
|
+
if (opts.fullSyncDaysLimit) parts.unshift(varintField(1, opts.fullSyncDaysLimit));
|
|
110
|
+
if (opts.fullSyncSizeMbLimit) parts.unshift(varintField(2, opts.fullSyncSizeMbLimit));
|
|
111
|
+
return Buffer.concat(parts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// DeviceProps.AppVersion — the companion's own version, not the account's.
|
|
115
|
+
function encodeDevicePropsVersion(primary, secondary, tertiary) {
|
|
116
|
+
return Buffer.concat([
|
|
117
|
+
varintField(1, primary),
|
|
118
|
+
varintField(2, secondary),
|
|
119
|
+
varintField(3, tertiary)
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function encodeDeviceProps(opts) {
|
|
124
|
+
opts = opts || {};
|
|
125
|
+
return Buffer.concat([
|
|
126
|
+
stringField(1, opts.os || 'Ubuntu'),
|
|
127
|
+
bytesField(2, encodeDevicePropsVersion(10, 15, 7)),
|
|
128
|
+
varintField(3, platformTypeFor(opts.browserName || 'Chrome')),
|
|
129
|
+
boolField(4, !!opts.requireFullSync),
|
|
130
|
+
bytesField(5, encodeHistorySyncConfig(opts.historySync))
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── ClientPayload.UserAgent ─────────────────────────────────────────────────
|
|
135
|
+
//
|
|
136
|
+
// Platform 14 = WEB. Carries no phone identifiers at all — no real mcc/mnc, no
|
|
137
|
+
// device id, no phone id. That absence is part of what marks the connection as
|
|
138
|
+
// a companion rather than a primary.
|
|
139
|
+
|
|
140
|
+
const PLATFORM_WEB = 14;
|
|
141
|
+
const PLATFORM_ANDROID = 0;
|
|
142
|
+
|
|
143
|
+
function encodeAppVersion(v) {
|
|
144
|
+
return Buffer.concat([
|
|
145
|
+
varintField(1, v[0]),
|
|
146
|
+
varintField(2, v[1]),
|
|
147
|
+
varintField(3, v[2])
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function encodeWebUserAgent(opts) {
|
|
152
|
+
const version = opts.version || [2, 3000, 1];
|
|
153
|
+
const browser = opts.browser || ['Ubuntu', 'Chrome', '120.0.0.0'];
|
|
154
|
+
const isAndroid = String(browser[1] || '').toLowerCase().includes('android');
|
|
155
|
+
return Buffer.concat([
|
|
156
|
+
varintField(1, isAndroid ? PLATFORM_ANDROID : PLATFORM_WEB),
|
|
157
|
+
bytesField(2, encodeAppVersion(version)),
|
|
158
|
+
stringField(3, '000'), // mcc
|
|
159
|
+
stringField(4, '000'), // mnc
|
|
160
|
+
stringField(5, '0.1'), // osVersion
|
|
161
|
+
stringField(6, 'Desktop'), // manufacturer
|
|
162
|
+
stringField(7, 'Desktop'), // device
|
|
163
|
+
stringField(8, '0.1'), // osBuildNumber
|
|
164
|
+
varintField(10, 0), // releaseChannel = RELEASE
|
|
165
|
+
stringField(11, 'en'), // localeLanguageIso6391
|
|
166
|
+
stringField(12, String(opts.countryCode || 'US')) // localeCountryIso31661Alpha2
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ClientPayload.WebInfo — webSubPlatform (field 4), WEB_BROWSER = 0.
|
|
171
|
+
function encodeWebInfo() {
|
|
172
|
+
return varintField(4, 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── ClientPayload.DevicePairingRegistrationData ─────────────────────────────
|
|
176
|
+
//
|
|
177
|
+
// The bundle the primary device signs. eIdent is our identity public key, and
|
|
178
|
+
// the account signature returned in pair-success is made over it — that is the
|
|
179
|
+
// link between this companion and the account.
|
|
180
|
+
function encodeDevicePairingData(o) {
|
|
181
|
+
return Buffer.concat([
|
|
182
|
+
bytesField(1, o.eRegid),
|
|
183
|
+
bytesField(2, o.eKeytype),
|
|
184
|
+
bytesField(3, o.eIdent),
|
|
185
|
+
bytesField(4, o.eSkeyId),
|
|
186
|
+
bytesField(5, o.eSkeyVal),
|
|
187
|
+
bytesField(6, o.eSkeySig),
|
|
188
|
+
bytesField(7, o.buildHash),
|
|
189
|
+
bytesField(8, o.deviceProps)
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── ClientPayload ───────────────────────────────────────────────────────────
|
|
194
|
+
//
|
|
195
|
+
// Field numbers, straight off the schema:
|
|
196
|
+
// 1 username · 3 passive · 5 userAgent · 6 webInfo · 7 pushName
|
|
197
|
+
// 12 connectType · 13 connectReason · 18 device · 19 devicePairingData
|
|
198
|
+
// 33 pull
|
|
199
|
+
|
|
200
|
+
const CONNECT_TYPE_WIFI_UNKNOWN = 1;
|
|
201
|
+
const CONNECT_REASON_USER_ACTIVATED = 1;
|
|
202
|
+
|
|
203
|
+
function commonWebPayload(opts) {
|
|
204
|
+
const parts = [
|
|
205
|
+
bytesField(5, encodeWebUserAgent(opts)),
|
|
206
|
+
bytesField(6, encodeWebInfo()),
|
|
207
|
+
varintField(12, CONNECT_TYPE_WIFI_UNKNOWN),
|
|
208
|
+
varintField(13, CONNECT_REASON_USER_ACTIVATED)
|
|
209
|
+
];
|
|
210
|
+
if (opts.pushName) parts.push(stringField(7, opts.pushName));
|
|
211
|
+
return parts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* First connection of a brand-new companion. There is no JID yet, so the
|
|
216
|
+
* payload carries the pairing bundle; the server answers with a pair-device
|
|
217
|
+
* (the QR path) or, once a pairing code has been requested and typed into the
|
|
218
|
+
* phone, with a pair-success.
|
|
219
|
+
*/
|
|
220
|
+
function encodeCompanionRegisterPayload(opts) {
|
|
221
|
+
const version = opts.version || [2, 3000, 1];
|
|
222
|
+
const buildHash = crypto.createHash('md5').update(version.join('.')).digest();
|
|
223
|
+
|
|
224
|
+
const deviceProps = encodeDeviceProps({
|
|
225
|
+
os: (opts.browser && opts.browser[0]) || 'Ubuntu',
|
|
226
|
+
browserName: (opts.browser && opts.browser[1]) || 'Chrome',
|
|
227
|
+
requireFullSync: !!opts.syncFullHistory,
|
|
228
|
+
historySync: opts.historySync
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const pairing = encodeDevicePairingData({
|
|
232
|
+
buildHash,
|
|
233
|
+
deviceProps,
|
|
234
|
+
eRegid: encodeBigEndian(opts.registrationId, 4),
|
|
235
|
+
eKeytype: Buffer.from([5]),
|
|
236
|
+
eIdent: stripKeyPrefix(opts.identityKeyPair.public),
|
|
237
|
+
eSkeyId: encodeBigEndian(opts.signedPreKey.id, 3),
|
|
238
|
+
eSkeyVal: stripKeyPrefix(opts.signedPreKey.public),
|
|
239
|
+
eSkeySig: opts.signedPreKey.signature
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return Buffer.concat([
|
|
243
|
+
...commonWebPayload(opts),
|
|
244
|
+
boolField(3, false), // passive
|
|
245
|
+
boolField(33, false), // pull
|
|
246
|
+
bytesField(19, pairing) // devicePairingData
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Every connection after pairing. `username` is the account's phone number and
|
|
252
|
+
* `device` is the index the server assigned us; together they form the JID the
|
|
253
|
+
* primary lists under Linked Devices.
|
|
254
|
+
*/
|
|
255
|
+
function encodeCompanionLoginPayload(opts) {
|
|
256
|
+
return Buffer.concat([
|
|
257
|
+
uint64Field(1, opts.username),
|
|
258
|
+
...commonWebPayload(opts),
|
|
259
|
+
boolField(3, true), // passive
|
|
260
|
+
boolField(33, true), // pull
|
|
261
|
+
varintField(18, opts.device || 0)
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
encodeCompanionRegisterPayload,
|
|
267
|
+
encodeCompanionLoginPayload,
|
|
268
|
+
encodeDeviceProps,
|
|
269
|
+
encodeHistorySyncConfig,
|
|
270
|
+
encodeBigEndian,
|
|
271
|
+
platformTypeFor,
|
|
272
|
+
PLATFORM_TYPE
|
|
273
|
+
};
|