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
|
@@ -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': '
|
|
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) => {
|
package/lib/MediaService.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|