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.
@@ -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
- ownLinkedJids = ownDevices.filter(j => j !== ownMainJid);
944
- 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();
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
- 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;
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
- const selfAddrs = new Set([adString(`${ownPhone}@s.whatsapp.net`)]);
1281
- 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`));
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
- 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' });
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
- 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) {
@@ -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
+ };