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,247 @@
1
+ 'use strict';
2
+
3
+ // pair-success: the moment a companion becomes part of the account.
4
+ //
5
+ // The primary device sends back an ADVSignedDeviceIdentity — a small record
6
+ // saying "this identity key is mine, at this key index, from this timestamp" —
7
+ // wrapped in an HMAC under adv_secret, the key both sides derived during the
8
+ // pairing exchange. Three checks have to pass before we accept it:
9
+ //
10
+ // 1. the HMAC, which proves it came from someone holding adv_secret;
11
+ // 2. the account signature over our own identity public key, which proves the
12
+ // primary really did authorise *this* device and not some other one;
13
+ // 3. nothing about our own key material has changed underneath us.
14
+ //
15
+ // Then we counter-sign with our identity key and hand it back. That signed
16
+ // record is what rides on every pkmsg we send from then on: it is how the
17
+ // recipient's client knows a message from device 7 of an account is genuinely
18
+ // from that account.
19
+
20
+ const crypto = require('crypto');
21
+ const curveJs = require('curve25519-js');
22
+
23
+ const WA_ADV_ACCOUNT_SIG_PREFIX = Buffer.from([6, 0]);
24
+ const WA_ADV_DEVICE_SIG_PREFIX = Buffer.from([6, 1]);
25
+ const WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX = Buffer.from([6, 5]);
26
+
27
+ const ADV_ENCRYPTION_TYPE_HOSTED = 1;
28
+
29
+ // ─── minimal protobuf reader ─────────────────────────────────────────────────
30
+ //
31
+ // Same approach the rest of the library takes: these messages are four fields
32
+ // wide and never change, so a reader is cheaper than a schema runtime.
33
+
34
+ function readVarint(buf, off) {
35
+ let v = 0, shift = 0;
36
+ while (off.pos < buf.length) {
37
+ const b = buf[off.pos++];
38
+ v += (b & 0x7f) * Math.pow(2, shift);
39
+ if (!(b & 0x80)) break;
40
+ shift += 7;
41
+ }
42
+ return v;
43
+ }
44
+
45
+ function readFields(buf) {
46
+ const fields = {};
47
+ const off = { pos: 0 };
48
+ while (off.pos < buf.length) {
49
+ const tag = readVarint(buf, off);
50
+ const fieldNum = tag >>> 3;
51
+ const wireType = tag & 7;
52
+ if (wireType === 0) {
53
+ fields[fieldNum] = readVarint(buf, off);
54
+ } else if (wireType === 2) {
55
+ const len = readVarint(buf, off);
56
+ fields[fieldNum] = buf.slice(off.pos, off.pos + len);
57
+ off.pos += len;
58
+ } else if (wireType === 5) {
59
+ fields[fieldNum] = buf.slice(off.pos, off.pos + 4);
60
+ off.pos += 4;
61
+ } else if (wireType === 1) {
62
+ fields[fieldNum] = buf.slice(off.pos, off.pos + 8);
63
+ off.pos += 8;
64
+ } else {
65
+ break;
66
+ }
67
+ }
68
+ return fields;
69
+ }
70
+
71
+ function varint(n) {
72
+ const out = [];
73
+ let v = n;
74
+ do {
75
+ let b = v & 0x7f;
76
+ v = Math.floor(v / 128);
77
+ if (v) b |= 0x80;
78
+ out.push(b);
79
+ } while (v);
80
+ return Buffer.from(out);
81
+ }
82
+
83
+ function bytesField(num, buf) {
84
+ return Buffer.concat([varint((num << 3) | 2), varint(buf.length), buf]);
85
+ }
86
+
87
+ /**
88
+ * Re-encode an ADVSignedDeviceIdentity, optionally without the account
89
+ * signature key.
90
+ *
91
+ * The reply we send back omits it — the primary already has its own key and
92
+ * echoing it adds nothing — while the copy we keep for pkmsg attachment keeps
93
+ * it, because recipients need it to verify our device signature.
94
+ */
95
+ function encodeSignedDeviceIdentity(account, includeSignatureKey) {
96
+ const parts = [bytesField(1, account.details)];
97
+ if (includeSignatureKey && account.accountSignatureKey && account.accountSignatureKey.length) {
98
+ parts.push(bytesField(2, account.accountSignatureKey));
99
+ }
100
+ if (account.accountSignature) parts.push(bytesField(3, account.accountSignature));
101
+ if (account.deviceSignature) parts.push(bytesField(4, account.deviceSignature));
102
+ return Buffer.concat(parts);
103
+ }
104
+
105
+ function stripKeyPrefix(pub) {
106
+ if (pub && pub.length === 33 && pub[0] === 0x05) return pub.slice(1);
107
+ return pub;
108
+ }
109
+
110
+ function findChild(node, desc) {
111
+ if (!node || !Array.isArray(node.content)) return null;
112
+ return node.content.find(c => c && c.description === desc) || null;
113
+ }
114
+
115
+ function jidToString(v) {
116
+ if (v == null) return null;
117
+ if (typeof v === 'string') return v;
118
+ // BinaryNode decodes JIDs into { user, device, server }
119
+ const user = v.user != null ? String(v.user) : '';
120
+ const server = v.server || 's.whatsapp.net';
121
+ const device = v.device ? ':' + v.device : '';
122
+ return user + device + '@' + server;
123
+ }
124
+
125
+ /**
126
+ * Validate a <pair-success> stanza and build the reply.
127
+ *
128
+ * @param {object} stanza the full <iq> containing <pair-success>
129
+ * @param {object} creds { advSecretKey, identityKeyPair }
130
+ * @returns {{reply: object, me: object, account: object, accountEnc: Buffer,
131
+ * platform: string|null, deviceIndex: number, keyIndex: number}}
132
+ */
133
+ function configureSuccessfulPairing(stanza, creds, BinaryNode) {
134
+ const msgId = stanza.attrs && stanza.attrs.id;
135
+
136
+ const pairSuccess = findChild(stanza, 'pair-success');
137
+ if (!pairSuccess) throw new Error('pair-success: node missing from stanza');
138
+
139
+ const deviceIdentityNode = findChild(pairSuccess, 'device-identity');
140
+ const platformNode = findChild(pairSuccess, 'platform');
141
+ const deviceNode = findChild(pairSuccess, 'device');
142
+ const businessNode = findChild(pairSuccess, 'biz');
143
+
144
+ if (!deviceIdentityNode || !deviceNode) {
145
+ throw new Error('pair-success: missing device-identity or device');
146
+ }
147
+
148
+ const hmacMsg = readFields(Buffer.from(deviceIdentityNode.content));
149
+ const details = hmacMsg[1];
150
+ const hmac = hmacMsg[2];
151
+ const accountType = hmacMsg[3];
152
+ if (!details || !hmac) throw new Error('pair-success: malformed ADVSignedDeviceIdentityHMAC');
153
+
154
+ // 1. HMAC — proves the record came from a holder of adv_secret.
155
+ const hmacPrefix = accountType === ADV_ENCRYPTION_TYPE_HOSTED
156
+ ? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
157
+ : Buffer.alloc(0);
158
+ const advSign = crypto.createHmac('sha256', Buffer.from(creds.advSecretKey, 'base64'))
159
+ .update(Buffer.concat([hmacPrefix, details]))
160
+ .digest();
161
+ if (Buffer.compare(hmac, advSign) !== 0) {
162
+ throw new Error('pair-success: invalid account signature HMAC — pairing code mismatch');
163
+ }
164
+
165
+ const account = readFields(details);
166
+ const accountSignatureKey = account[2];
167
+ const accountSignature = account[3];
168
+ const deviceDetails = account[1];
169
+ if (!accountSignatureKey || !accountSignature || !deviceDetails) {
170
+ throw new Error('pair-success: malformed ADVSignedDeviceIdentity');
171
+ }
172
+
173
+ const deviceIdentity = readFields(deviceDetails);
174
+ const keyIndex = deviceIdentity[3] || 0;
175
+ const deviceType = deviceIdentity[5];
176
+
177
+ // 2. Account signature over our identity key — proves the primary authorised
178
+ // this exact device.
179
+ const ourIdentityPub = stripKeyPrefix(creds.identityKeyPair.public);
180
+ const accountSigPrefix = deviceType === ADV_ENCRYPTION_TYPE_HOSTED
181
+ ? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
182
+ : WA_ADV_ACCOUNT_SIG_PREFIX;
183
+ const accountMsg = Buffer.concat([accountSigPrefix, deviceDetails, ourIdentityPub]);
184
+ if (!curveJs.verify(stripKeyPrefix(accountSignatureKey), accountMsg, accountSignature)) {
185
+ throw new Error('pair-success: failed to verify account signature');
186
+ }
187
+
188
+ // 3. Counter-sign. This is the signature recipients check on our pkmsgs.
189
+ const deviceMsg = Buffer.concat([
190
+ WA_ADV_DEVICE_SIG_PREFIX,
191
+ deviceDetails,
192
+ ourIdentityPub,
193
+ stripKeyPrefix(accountSignatureKey)
194
+ ]);
195
+ const deviceSignature = Buffer.from(
196
+ curveJs.sign(creds.identityKeyPair.private, deviceMsg)
197
+ );
198
+
199
+ const signedAccount = {
200
+ details: deviceDetails,
201
+ accountSignatureKey: stripKeyPrefix(accountSignatureKey),
202
+ accountSignature,
203
+ deviceSignature
204
+ };
205
+
206
+ const jid = jidToString(deviceNode.attrs && deviceNode.attrs.jid);
207
+ const lid = jidToString(deviceNode.attrs && deviceNode.attrs.lid);
208
+ const deviceIndex = jid && jid.includes(':')
209
+ ? parseInt(jid.split('@')[0].split(':')[1], 10) || 0
210
+ : 0;
211
+
212
+ const reply = new BinaryNode('iq', {
213
+ to: 's.whatsapp.net',
214
+ type: 'result',
215
+ id: msgId
216
+ }, [
217
+ new BinaryNode('pair-device-sign', {}, [
218
+ new BinaryNode('device-identity', { 'key-index': String(keyIndex) },
219
+ encodeSignedDeviceIdentity(signedAccount, false))
220
+ ])
221
+ ]);
222
+
223
+ return {
224
+ reply,
225
+ me: {
226
+ id: jid,
227
+ lid: lid,
228
+ name: (businessNode && businessNode.attrs && businessNode.attrs.name) || null
229
+ },
230
+ account: signedAccount,
231
+ // What gets persisted and attached to outgoing pkmsg stanzas — with the
232
+ // account signature key, since recipients need it to check our signature.
233
+ accountEnc: encodeSignedDeviceIdentity(signedAccount, true),
234
+ platform: (platformNode && platformNode.attrs && platformNode.attrs.name) || null,
235
+ deviceIndex,
236
+ keyIndex
237
+ };
238
+ }
239
+
240
+ module.exports = {
241
+ configureSuccessfulPairing,
242
+ encodeSignedDeviceIdentity,
243
+ readFields,
244
+ WA_ADV_ACCOUNT_SIG_PREFIX,
245
+ WA_ADV_DEVICE_SIG_PREFIX,
246
+ WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
247
+ };
@@ -441,9 +441,14 @@ function downloadUrl(url) {
441
441
  const driver = parsed.protocol === 'https:' ? https : http;
442
442
  const chunks = [];
443
443
 
444
+ // History only ever reaches a companion device, and the CDN treats a
445
+ // companion as a browser: it wants an Origin, and a mobile WhatsApp
446
+ // User-Agent against a web-issued blob is a mismatch it can refuse.
444
447
  const req = driver.get(url, {
445
448
  headers: {
446
- 'User-Agent': 'WhatsApp/2.24.20.82 A',
449
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
450
+ '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
451
+ 'Origin': 'https://web.whatsapp.com',
447
452
  'Accept': '*/*'
448
453
  }
449
454
  }, (res) => {
@@ -19,6 +19,48 @@ const MEDIA_PATH = {
19
19
  gif: { path: 'mms/gif', keyName: 'WhatsApp Video Keys' }
20
20
  };
21
21
 
22
+ // The web client does not have a path per media type. A sticker is uploaded to
23
+ // the image endpoint, a GIF and a voice note to the video and audio ones — the
24
+ // distinction lives in the message protobuf, not in the URL. Uploading a
25
+ // sticker to /mms/sticker as a companion gets a 404 from the CDN.
26
+ //
27
+ // The encryption key names are unchanged; those come from the media type and
28
+ // have nothing to do with the endpoint.
29
+ const WEB_MEDIA_PATH = {
30
+ image: 'mms/image',
31
+ video: 'mms/video',
32
+ audio: 'mms/audio',
33
+ ptt: 'mms/audio',
34
+ document: 'mms/document',
35
+ sticker: 'mms/image',
36
+ gif: 'mms/video'
37
+ };
38
+
39
+ const WEB_ORIGIN_HEADER = 'https://web.whatsapp.com';
40
+ const WEB_USER_AGENT =
41
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
42
+ 'Chrome/120.0.0.0 Safari/537.36';
43
+
44
+ // A companion is a browser as far as the CDN is concerned: it wants an Origin,
45
+ // and a mobile WhatsApp User-Agent on a web-issued auth token is a mismatch it
46
+ // can reject. A primary sends neither, exactly as before.
47
+ function mediaHeaders(web) {
48
+ if (!web) {
49
+ const d = getDeviceConfig();
50
+ return {
51
+ 'User-Agent': d.os === 'android'
52
+ ? `WhatsApp/${d.version || '2.26.7.75'} A`
53
+ : `WhatsApp/${d.version || '2.26.7.75'} iOS/${d.osVersion} Device/${d.model}`
54
+ };
55
+ }
56
+ return { 'User-Agent': WEB_USER_AGENT, 'Origin': WEB_ORIGIN_HEADER };
57
+ }
58
+
59
+ function mediaPathFor(mediaType, web) {
60
+ if (web) return WEB_MEDIA_PATH[mediaType] || (MEDIA_PATH[mediaType] || {}).path;
61
+ return (MEDIA_PATH[mediaType] || {}).path;
62
+ }
63
+
22
64
  const EXPANDED_SIZE = 112;
23
65
  const IV_LEN = 16;
24
66
  const KEY_LEN = 32;
@@ -83,9 +125,11 @@ function decryptMedia(encryptedWithMac, mediaKey, keyName) {
83
125
  // retried with the fresh credentials. Without this a token that expires between
84
126
  // the TTL check and the upload burns through every host and fails permanently
85
127
  // ("connection expired"), because each retry reuses the same stale auth.
86
- async function uploadMedia(mediaType, data, hosts, auth, refreshAuth) {
128
+ async function uploadMedia(mediaType, data, hosts, auth, refreshAuth, opts) {
87
129
  const typeInfo = MEDIA_PATH[mediaType];
88
130
  if (!typeInfo) throw new Error('Unknown media type: ' + mediaType);
131
+ const web = !!(opts && opts.web);
132
+ const uploadPath = mediaPathFor(mediaType, web);
89
133
 
90
134
  const mediaKey = crypto.randomBytes(32);
91
135
  const plaintext = Buffer.isBuffer(data) ? data : fs.readFileSync(data);
@@ -115,7 +159,7 @@ async function uploadMedia(mediaType, data, hosts, auth, refreshAuth) {
115
159
  for (const host of currentHosts) {
116
160
  let res;
117
161
  try {
118
- res = await _tryUpload(host, typeInfo.path, token, authEncoded, encrypted);
162
+ res = await _tryUpload(host, uploadPath, token, authEncoded, encrypted, web);
119
163
  } catch (_) {
120
164
  continue;
121
165
  }
@@ -146,7 +190,7 @@ async function uploadMedia(mediaType, data, hosts, auth, refreshAuth) {
146
190
 
147
191
  // Resolves { status, body } — status 0 marks a transport-level failure, so the
148
192
  // caller can tell an expired auth (4xx) apart from an unreachable host.
149
- function _tryUpload(host, mediaPath, token, auth, encrypted) {
193
+ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
150
194
  return new Promise((resolve) => {
151
195
  const hostname = typeof host === 'string' ? host : host.hostname;
152
196
  const urlPath = `/${mediaPath}/${token}?auth=${auth}&token=${token}`;
@@ -156,12 +200,11 @@ function _tryUpload(host, mediaPath, token, auth, encrypted) {
156
200
  port: 443,
157
201
  path: urlPath,
158
202
  method: 'POST',
159
- headers: {
203
+ headers: Object.assign({
160
204
  'Content-Type': 'application/octet-stream',
161
205
  'Content-Length': encrypted.length,
162
- 'Accept': 'application/json',
163
- 'User-Agent': (() => { const d = getDeviceConfig(); return d.os === 'android' ? `WhatsApp/${d.version || '2.26.7.75'} A` : `WhatsApp/${d.version || '2.26.7.75'} iOS/${d.osVersion} Device/${d.model}`; })()
164
- }
206
+ 'Accept': 'application/json'
207
+ }, mediaHeaders(web))
165
208
  };
166
209
 
167
210
  const req = https.request(options, (res) => {
@@ -180,17 +223,20 @@ function _tryUpload(host, mediaPath, token, auth, encrypted) {
180
223
  });
181
224
  }
182
225
 
183
- async function downloadMedia(url, mediaKey, keyName) {
184
- const encrypted = await _httpGet(url);
226
+ async function downloadMedia(url, mediaKey, keyName, opts) {
227
+ const encrypted = await _httpGet(url, opts);
185
228
  return decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
186
229
  }
187
230
 
188
- function _httpGet(url) {
231
+ // The CDN applies the same Origin check on the way down as on the way up, so a
232
+ // companion has to identify itself as a browser here too.
233
+ function _httpGet(url, opts) {
189
234
  return new Promise((resolve, reject) => {
190
235
  const lib = url.startsWith('https') ? https : http;
191
- lib.get(url, (res) => {
236
+ const web = !!(opts && opts.web);
237
+ lib.get(url, { headers: mediaHeaders(web) }, (res) => {
192
238
  if (res.statusCode === 301 || res.statusCode === 302) {
193
- return _httpGet(res.headers.location).then(resolve).catch(reject);
239
+ return _httpGet(res.headers.location, opts).then(resolve).catch(reject);
194
240
  }
195
241
  const chunks = [];
196
242
  res.on('data', c => chunks.push(c));
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ // Pairing by 8-character code, the alternative to scanning a QR.
4
+ //
5
+ // The account owner opens Linked Devices on their phone, picks "Link with phone
6
+ // number instead", and types in the code this module generates. That code is
7
+ // never sent to the server in the clear: it is a password, and both sides run
8
+ // it through PBKDF2 to agree on a key that wraps the ephemeral public keys they
9
+ // exchange. Someone who watches the whole conversation still cannot derive the
10
+ // shared secret without the eight characters the human typed.
11
+ //
12
+ // The exchange, in order:
13
+ //
14
+ // 1. companion_hello — we send our ephemeral public key, encrypted under
15
+ // the pairing code, plus our noise public key.
16
+ // 2. (human types the code into their phone)
17
+ // 3. companion_finish — the server pushes the primary's wrapped ephemeral
18
+ // key and identity key. We unwrap, do two Diffie-
19
+ // Hellmans, and derive adv_secret — the key the
20
+ // primary's account signature will be verified with.
21
+ // 4. pair-success — the primary's signed device identity arrives; we
22
+ // verify it, counter-sign, and reply.
23
+ //
24
+ // After that the server drops the connection with a 515 and the next one logs
25
+ // in normally.
26
+
27
+ const crypto = require('crypto');
28
+ const curveJs = require('curve25519-js');
29
+
30
+ // Crockford base32 minus the ambiguous characters — no I, O, U, and no 0.
31
+ // Five random bytes become exactly eight characters.
32
+ const CROCKFORD_CHARACTERS = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';
33
+
34
+ function bytesToCrockford(buffer) {
35
+ let value = 0;
36
+ let bitCount = 0;
37
+ const out = [];
38
+
39
+ for (const element of buffer) {
40
+ value = (value << 8) | (element & 0xff);
41
+ bitCount += 8;
42
+ while (bitCount >= 5) {
43
+ out.push(CROCKFORD_CHARACTERS.charAt((value >>> (bitCount - 5)) & 31));
44
+ bitCount -= 5;
45
+ }
46
+ }
47
+ if (bitCount > 0) {
48
+ out.push(CROCKFORD_CHARACTERS.charAt((value << (5 - bitCount)) & 31));
49
+ }
50
+ return out.join('');
51
+ }
52
+
53
+ function generatePairingCode() {
54
+ return bytesToCrockford(crypto.randomBytes(5));
55
+ }
56
+
57
+ /**
58
+ * PBKDF2-SHA256 over the pairing code, 2^17 iterations, 32-byte output.
59
+ *
60
+ * The iteration count is what makes the eight characters worth anything: it
61
+ * costs an attacker who captured the handshake roughly that many hashes per
62
+ * guess across a 32^8 space.
63
+ */
64
+ function derivePairingCodeKey(pairingCode, salt) {
65
+ return new Promise((resolve, reject) => {
66
+ crypto.pbkdf2(Buffer.from(String(pairingCode), 'utf8'), salt, 2 << 16, 32, 'sha256',
67
+ (err, key) => (err ? reject(err) : resolve(key)));
68
+ });
69
+ }
70
+
71
+ function aesEncryptCTR(plaintext, key, iv) {
72
+ const c = crypto.createCipheriv('aes-256-ctr', key, iv);
73
+ return Buffer.concat([c.update(plaintext), c.final()]);
74
+ }
75
+
76
+ function aesDecryptCTR(ciphertext, key, iv) {
77
+ const d = crypto.createDecipheriv('aes-256-ctr', key, iv);
78
+ return Buffer.concat([d.update(ciphertext), d.final()]);
79
+ }
80
+
81
+ function aesEncryptGCM(plaintext, key, iv, aad) {
82
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
83
+ c.setAAD(aad || Buffer.alloc(0));
84
+ return Buffer.concat([c.update(plaintext), c.final(), c.getAuthTag()]);
85
+ }
86
+
87
+ function hkdf(input, length, info, salt) {
88
+ const { hkdf: nobleHkdf } = require('@noble/hashes/hkdf');
89
+ const { sha256 } = require('@noble/hashes/sha256');
90
+ return Buffer.from(nobleHkdf(
91
+ sha256,
92
+ input,
93
+ salt || Buffer.alloc(32),
94
+ info ? Buffer.from(info, 'utf8') : Buffer.alloc(0),
95
+ length
96
+ ));
97
+ }
98
+
99
+ function stripKeyPrefix(pub) {
100
+ if (pub && pub.length === 33 && pub[0] === 0x05) return pub.slice(1);
101
+ return pub;
102
+ }
103
+
104
+ function sharedKey(priv, pub) {
105
+ return Buffer.from(curveJs.sharedKey(stripKeyPrefix(priv), stripKeyPrefix(pub)));
106
+ }
107
+
108
+ function generateEphemeralKeyPair() {
109
+ const kp = curveJs.generateKeyPair(crypto.randomBytes(32));
110
+ return { private: Buffer.from(kp.private), public: Buffer.from(kp.public) };
111
+ }
112
+
113
+ /**
114
+ * Wrap our ephemeral public key for the companion_hello node.
115
+ *
116
+ * Layout: salt(32) || iv(16) || AES-256-CTR(ephemeral public key, 32)
117
+ * The primary reproduces the key from the code the human typed and unwraps it.
118
+ */
119
+ async function buildWrappedEphemeralPub(pairingCode, ephemeralPublicKey) {
120
+ const salt = crypto.randomBytes(32);
121
+ const iv = crypto.randomBytes(16);
122
+ const key = await derivePairingCodeKey(pairingCode, salt);
123
+ const ciphered = aesEncryptCTR(stripKeyPrefix(ephemeralPublicKey), key, iv);
124
+ return Buffer.concat([salt, iv, ciphered]);
125
+ }
126
+
127
+ /** The inverse, applied to the primary's wrapped ephemeral key. */
128
+ async function decipherLinkPublicKey(data, pairingCode) {
129
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
130
+ if (buf.length < 80) {
131
+ throw new Error('link_code_pairing_wrapped_primary_ephemeral_pub too short: ' + buf.length);
132
+ }
133
+ const salt = buf.slice(0, 32);
134
+ const iv = buf.slice(32, 48);
135
+ const payload = buf.slice(48, 80);
136
+ const secretKey = await derivePairingCodeKey(pairingCode, salt);
137
+ return aesDecryptCTR(payload, secretKey, iv);
138
+ }
139
+
140
+ /**
141
+ * Stage 3. Given what the server pushed, derive adv_secret and produce the
142
+ * key bundle the primary needs to finish the link.
143
+ *
144
+ * adv_secret is the whole point of the exchange: every subsequent proof that
145
+ * this device belongs to the account is an HMAC under it.
146
+ *
147
+ * @returns {{advSecretKey: string, wrappedKeyBundle: Buffer}}
148
+ */
149
+ async function buildCompanionFinishBundle(opts) {
150
+ const {
151
+ pairingCode,
152
+ pairingEphemeralPrivate,
153
+ primaryEphemeralPubWrapped,
154
+ primaryIdentityPub,
155
+ identityKeyPair
156
+ } = opts;
157
+
158
+ const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralPubWrapped, pairingCode);
159
+ const companionSharedKey = sharedKey(pairingEphemeralPrivate, codePairingPublicKey);
160
+
161
+ const random = crypto.randomBytes(32);
162
+ const linkCodeSalt = crypto.randomBytes(32);
163
+ const expanded = hkdf(companionSharedKey, 32, 'link_code_pairing_key_bundle_encryption_key', linkCodeSalt);
164
+
165
+ const encryptPayload = Buffer.concat([
166
+ stripKeyPrefix(identityKeyPair.public),
167
+ stripKeyPrefix(primaryIdentityPub),
168
+ random
169
+ ]);
170
+ const encryptIv = crypto.randomBytes(12);
171
+ const encrypted = aesEncryptGCM(encryptPayload, expanded, encryptIv, Buffer.alloc(0));
172
+
173
+ const identitySharedKey = sharedKey(identityKeyPair.private, primaryIdentityPub);
174
+ const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]);
175
+ const advSecretKey = hkdf(identityPayload, 32, 'adv_secret').toString('base64');
176
+
177
+ return {
178
+ advSecretKey,
179
+ wrappedKeyBundle: Buffer.concat([linkCodeSalt, encryptIv, encrypted])
180
+ };
181
+ }
182
+
183
+ module.exports = {
184
+ CROCKFORD_CHARACTERS,
185
+ bytesToCrockford,
186
+ generatePairingCode,
187
+ generateEphemeralKeyPair,
188
+ derivePairingCodeKey,
189
+ buildWrappedEphemeralPub,
190
+ decipherLinkPublicKey,
191
+ buildCompanionFinishBundle,
192
+ aesEncryptCTR,
193
+ aesDecryptCTR,
194
+ aesEncryptGCM,
195
+ hkdf,
196
+ sharedKey,
197
+ stripKeyPrefix
198
+ };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ // A WebSocket that behaves like a net.Socket.
4
+ //
5
+ // NoiseSocket was written against a raw TLS socket to g.whatsapp.net and knows
6
+ // nothing about WebSockets. Rather than fork it, this wraps `ws` in the same
7
+ // small surface NoiseSocket already uses — write/destroy/setKeepAlive plus the
8
+ // connect/data/error/close events — so a single handshake implementation drives
9
+ // both transports.
10
+ //
11
+ // The length framing is unchanged: even inside a WebSocket message WhatsApp
12
+ // still prefixes each frame with three big-endian length bytes, so the existing
13
+ // buffer loop applies as-is.
14
+
15
+ const EventEmitter = require('events');
16
+ const { WEB_SOCKET_URL, WEB_ORIGIN } = require('./constants');
17
+
18
+ function loadWs() {
19
+ try {
20
+ return require('ws');
21
+ } catch (_) {
22
+ throw new Error(
23
+ "The 'ws' package is required for WhatsApp Web mode. Install it with: npm install ws"
24
+ );
25
+ }
26
+ }
27
+
28
+ class WebSocketStream extends EventEmitter {
29
+ constructor(opts) {
30
+ super();
31
+ opts = opts || {};
32
+ this._url = opts.url || WEB_SOCKET_URL;
33
+ this._origin = opts.origin || WEB_ORIGIN;
34
+ this._ws = null;
35
+ this._closed = false;
36
+ this._opened = false;
37
+ }
38
+
39
+ connect() {
40
+ const WebSocket = loadWs();
41
+ this._ws = new WebSocket(this._url, {
42
+ origin: this._origin,
43
+ headers: {
44
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
45
+ '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
46
+ },
47
+ handshakeTimeout: 20000
48
+ });
49
+
50
+ this._ws.on('open', () => {
51
+ this._opened = true;
52
+ this.emit('connect');
53
+ });
54
+
55
+ // ws hands us a Buffer for binary frames; normalise anything else so the
56
+ // caller only ever deals in Buffers.
57
+ this._ws.on('message', (data) => {
58
+ this.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data));
59
+ });
60
+
61
+ this._ws.on('error', (err) => this.emit('error', err));
62
+
63
+ this._ws.on('close', () => {
64
+ if (this._closed) return;
65
+ this._closed = true;
66
+ this.emit('close');
67
+ });
68
+
69
+ return this;
70
+ }
71
+
72
+ write(buf) {
73
+ if (!this._ws || this._ws.readyState !== 1) return false;
74
+ this._ws.send(buf);
75
+ return true;
76
+ }
77
+
78
+ // net.Socket parity — a WebSocket has no equivalent knob, and `ws` sends its
79
+ // own pings, so this is deliberately a no-op rather than an error.
80
+ setKeepAlive() { /* no-op */ }
81
+
82
+ destroy() {
83
+ if (!this._ws) return;
84
+ try {
85
+ // terminate() rather than close(): we are tearing down, not negotiating,
86
+ // and a half-open socket here would leave the client waiting on a reply
87
+ // that is never coming.
88
+ this._ws.terminate();
89
+ } catch (_) {}
90
+ this._ws = null;
91
+ if (!this._closed) {
92
+ this._closed = true;
93
+ this.emit('close');
94
+ }
95
+ }
96
+ }
97
+
98
+ module.exports = { WebSocketStream };