whalibmob 2.1.1 → 3.3.1
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/lib/DeviceManager.js +9 -0
- package/lib/Registration.js +1 -1
- package/lib/Store.js +3 -2
- package/lib/messages/MessageSender.js +34 -5
- package/lib/noise.js +58 -2
- package/lib/proto/MessageProto.js +11 -7
- package/lib/proto.js +1 -1
- package/lib/signal/SignalProtocol.js +45 -18
- package/package.json +1 -1
package/lib/DeviceManager.js
CHANGED
|
@@ -224,6 +224,15 @@ class DeviceManager {
|
|
|
224
224
|
return readyJids;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// ─── Clear device cache (used on phash mismatch / 421 retry) ──────────────
|
|
228
|
+
clearCache(phones) {
|
|
229
|
+
if (!phones || phones.length === 0) {
|
|
230
|
+
this._deviceCache.clear();
|
|
231
|
+
} else {
|
|
232
|
+
for (const p of phones) this._deviceCache.delete(p);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
227
236
|
// ─── Build participants node ───────────────────────────────────────────────
|
|
228
237
|
static buildParticipantsNode(encryptedList) {
|
|
229
238
|
const toNodes = encryptedList.map(({ jid, type, ciphertext }) =>
|
package/lib/Registration.js
CHANGED
|
@@ -242,7 +242,7 @@ function buildPayload(store, waVersion, useToken, extraPairs) {
|
|
|
242
242
|
'rc', String(RELEASE_CHANNEL),
|
|
243
243
|
'lg', meta.lg,
|
|
244
244
|
'lc', meta.lc,
|
|
245
|
-
'authkey', store.noiseKeyPair.public.toString('base64url'),
|
|
245
|
+
'authkey', rawKey(store.noiseKeyPair.public).toString('base64url'),
|
|
246
246
|
'e_regid', intToBytes(store.registrationId, 4).toString('base64url'),
|
|
247
247
|
'e_keytype', Buffer.from([SIGNAL_KEY_TYPE]).toString('base64url'),
|
|
248
248
|
'e_ident', rawKey(store.identityKeyPair.public).toString('base64url'),
|
package/lib/Store.js
CHANGED
|
@@ -200,14 +200,15 @@ function fromSixParts(sixParts) {
|
|
|
200
200
|
private: spkPair.private,
|
|
201
201
|
signature: sig
|
|
202
202
|
},
|
|
203
|
-
registrationId: 1,
|
|
203
|
+
registrationId: (crypto.randomBytes(2).readUInt16BE(0) & 0x3fff) + 1,
|
|
204
204
|
fdid: uuidv4(),
|
|
205
205
|
deviceId: crypto.randomBytes(16),
|
|
206
206
|
identityId,
|
|
207
207
|
registered: true,
|
|
208
208
|
name: 'User',
|
|
209
209
|
version: IOS_VERSION_FALLBACK,
|
|
210
|
-
device: IOS_DEVICE
|
|
210
|
+
device: IOS_DEVICE,
|
|
211
|
+
advIdentity: null
|
|
211
212
|
};
|
|
212
213
|
}
|
|
213
214
|
|
|
@@ -281,7 +281,7 @@ class MessageSender {
|
|
|
281
281
|
isAnimated: options.isAnimated || false,
|
|
282
282
|
contextInfo: options.contextInfo
|
|
283
283
|
}));
|
|
284
|
-
return this._sendMessage(toJid, msgId, stkBuf, '
|
|
284
|
+
return this._sendMessage(toJid, msgId, stkBuf, 'media', options);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
// ─── Reaction ─────────────────────────────────────────────────────────────
|
|
@@ -302,9 +302,31 @@ class MessageSender {
|
|
|
302
302
|
|
|
303
303
|
async _sendMessage(toJid, msgId, plaintext, mediaType, options) {
|
|
304
304
|
if (isGroupJid(toJid)) {
|
|
305
|
-
|
|
305
|
+
try {
|
|
306
|
+
return await this._sendGroupMessage(toJid, msgId, plaintext, mediaType);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// Error 421 = phash mismatch — server's participant list differs from ours.
|
|
309
|
+
// Flush device cache for group members and retry once (matches Cobalt behaviour).
|
|
310
|
+
if (err && /\b421\b/.test(err.message)) {
|
|
311
|
+
const members = this._client._getGroupMembers
|
|
312
|
+
? this._client._getGroupMembers(toJid)
|
|
313
|
+
: [];
|
|
314
|
+
this._devMgr.clearCache(members.map(phoneFromJid));
|
|
315
|
+
return this._sendGroupMessage(toJid, msgId, plaintext, mediaType);
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
return await this._sendDMMessage(toJid, msgId, plaintext, mediaType);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// Same phash-mismatch retry for 1-to-1 messages.
|
|
324
|
+
if (err && /\b421\b/.test(err.message)) {
|
|
325
|
+
this._devMgr.clearCache([phoneFromJid(toJid)]);
|
|
326
|
+
return this._sendDMMessage(toJid, msgId, plaintext, mediaType);
|
|
327
|
+
}
|
|
328
|
+
throw err;
|
|
306
329
|
}
|
|
307
|
-
return this._sendDMMessage(toJid, msgId, plaintext, mediaType);
|
|
308
330
|
}
|
|
309
331
|
|
|
310
332
|
// ─── 1-to-1 multi-device fanout ───────────────────────────────────────────
|
|
@@ -403,6 +425,13 @@ class MessageSender {
|
|
|
403
425
|
|
|
404
426
|
const allTargets = [...memberDevices, ...ownDevices];
|
|
405
427
|
|
|
428
|
+
// phash MUST include the sender's own primary device.
|
|
429
|
+
// Cobalt's calculateGroupPhash() explicitly adds senderDevice to the set.
|
|
430
|
+
// ownDevices contains only linked devices (device != 0); ownJid is device 0.
|
|
431
|
+
const phashTargets = allTargets.includes(ownJid)
|
|
432
|
+
? allTargets
|
|
433
|
+
: [ownJid, ...allTargets];
|
|
434
|
+
|
|
406
435
|
// senderKeyMap: only send SKDM to devices that haven't received it yet.
|
|
407
436
|
// Persisted in .sk.json so we don't re-send on every group message.
|
|
408
437
|
const skStore = this._signal.senderKeyStore;
|
|
@@ -418,8 +447,8 @@ class MessageSender {
|
|
|
418
447
|
// SenderKey encrypt the actual group message (one ciphertext for all)
|
|
419
448
|
const skmsgCiphertext = this._signal.senderKeyEncrypt(groupJid, ownJid, plaintext);
|
|
420
449
|
|
|
421
|
-
// phash over all group member devices
|
|
422
|
-
const phash =
|
|
450
|
+
// phash over all group member devices + sender primary device
|
|
451
|
+
const phash = phashTargets.length > 0 ? computePhash(phashTargets) : null;
|
|
423
452
|
|
|
424
453
|
// ── Feature 2: device_identity for pkmsg in SKDM ─────────────────────────
|
|
425
454
|
// SKDM messages sent to devices with no prior Signal session are pkmsg.
|
package/lib/noise.js
CHANGED
|
@@ -10,6 +10,61 @@ const { MOBILE_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants')
|
|
|
10
10
|
const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
|
|
11
11
|
decodeServerHello, encodeClientPayload } = require('./proto');
|
|
12
12
|
|
|
13
|
+
// ─── CertChain validator (matches Baileys WA_CERT_DETAILS.SERIAL = 0) ────────
|
|
14
|
+
//
|
|
15
|
+
// Server payload inside ServerHello is a CertChain protobuf:
|
|
16
|
+
// CertChain.field2 (intermediate) → NoiseCertificate
|
|
17
|
+
// NoiseCertificate.field1 (details) → NoiseCertificate.Details
|
|
18
|
+
// Details.field2 (issuerSerial) must === 0
|
|
19
|
+
//
|
|
20
|
+
// All decoded manually to avoid a protobufjs dependency in noise.js.
|
|
21
|
+
|
|
22
|
+
function _pbReadVarint(buf, off) {
|
|
23
|
+
let v = 0, shift = 0;
|
|
24
|
+
while (off.pos < buf.length) {
|
|
25
|
+
const b = buf[off.pos++];
|
|
26
|
+
v |= (b & 0x7f) << shift;
|
|
27
|
+
if (!(b & 0x80)) break;
|
|
28
|
+
shift += 7;
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _pbReadFields(buf) {
|
|
34
|
+
const fields = {};
|
|
35
|
+
const off = { pos: 0 };
|
|
36
|
+
while (off.pos < buf.length) {
|
|
37
|
+
const tag = _pbReadVarint(buf, off);
|
|
38
|
+
const fn = tag >>> 3;
|
|
39
|
+
const wt = tag & 7;
|
|
40
|
+
if (wt === 0) {
|
|
41
|
+
fields[fn] = _pbReadVarint(buf, off);
|
|
42
|
+
} else if (wt === 2) {
|
|
43
|
+
const len = _pbReadVarint(buf, off);
|
|
44
|
+
fields[fn] = buf.slice(off.pos, off.pos + len);
|
|
45
|
+
off.pos += len;
|
|
46
|
+
} else {
|
|
47
|
+
break; // unexpected wire type — stop parsing
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return fields;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateServerCert(payloadBuf) {
|
|
54
|
+
// payloadBuf is the decrypted ServerHello.payload (CertChain protobuf)
|
|
55
|
+
const chain = _pbReadFields(payloadBuf);
|
|
56
|
+
const intermediate = chain[2]; // field 2 = intermediate NoiseCertificate
|
|
57
|
+
if (!Buffer.isBuffer(intermediate)) return; // no intermediate cert — skip
|
|
58
|
+
const cert = _pbReadFields(intermediate);
|
|
59
|
+
const details = cert[1]; // field 1 = details bytes
|
|
60
|
+
if (!Buffer.isBuffer(details)) return;
|
|
61
|
+
const det = _pbReadFields(details);
|
|
62
|
+
const issuerSerial = det[2]; // field 2 = issuerSerial (uint32)
|
|
63
|
+
if (issuerSerial !== undefined && issuerSerial !== 0) {
|
|
64
|
+
throw new Error('WA server cert validation failed: issuerSerial=' + issuerSerial + ' expected 0');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
// ─── Curve25519 DH helpers ───────────────────────────────────────────────────
|
|
14
69
|
|
|
15
70
|
function stripKeyPrefix(pub) {
|
|
@@ -216,7 +271,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
216
271
|
this.noiseState.mixKey(sharedSE);
|
|
217
272
|
|
|
218
273
|
if (serverHello.payload) {
|
|
219
|
-
this.noiseState.decryptWithAd(serverHello.payload);
|
|
274
|
+
const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
|
|
275
|
+
validateServerCert(certPlain);
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
const noisePriv = this.store.noiseKeyPair.private;
|
|
@@ -229,7 +285,7 @@ class NoiseSocket extends EventEmitter {
|
|
|
229
285
|
const payload = encodeClientPayload({
|
|
230
286
|
username: BigInt(this.store.phoneNumber),
|
|
231
287
|
passive: false,
|
|
232
|
-
pushName: this.store.name ||
|
|
288
|
+
pushName: this.store.registered ? (this.store.name || null) : null,
|
|
233
289
|
shortConnect: true,
|
|
234
290
|
connectType: 1,
|
|
235
291
|
connectReason: 1,
|
|
@@ -8,11 +8,13 @@ function tag(fieldNumber, wireType) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
function encodeVarint(n) {
|
|
11
|
+
// Use Math.floor/% instead of bitwise ops to handle values > 2^31 safely
|
|
12
|
+
// (JavaScript bitwise ops coerce to Int32, breaking timestamps in ms, large file sizes etc.)
|
|
11
13
|
const bytes = [];
|
|
12
|
-
let val = n
|
|
14
|
+
let val = Math.floor(n);
|
|
13
15
|
while (val > 127) {
|
|
14
|
-
bytes.push((val
|
|
15
|
-
val = val
|
|
16
|
+
bytes.push((val % 128) | 0x80);
|
|
17
|
+
val = Math.floor(val / 128);
|
|
16
18
|
}
|
|
17
19
|
bytes.push(val);
|
|
18
20
|
return Buffer.from(bytes);
|
|
@@ -101,23 +103,25 @@ function encodeVideoMessage(opts) {
|
|
|
101
103
|
field(9, WIRE_VARINT, varint(opts.height || 0)),
|
|
102
104
|
field(10, WIRE_VARINT, varint(opts.width || 0)),
|
|
103
105
|
field(11, WIRE_LEN, bytes(opts.fileEncSha256)),
|
|
104
|
-
field(
|
|
105
|
-
field(
|
|
106
|
+
field(13, WIRE_LEN, str(opts.directPath)),
|
|
107
|
+
field(14, WIRE_VARINT, varintS(opts.mediaKeyTimestamp || Math.floor(Date.now() / 1000)))
|
|
106
108
|
];
|
|
107
|
-
if (opts.jpegThumbnail) parts.push(field(16, WIRE_LEN, bytes(opts.jpegThumbnail)));
|
|
108
109
|
if (opts.gifPlayback) parts.push(field(8, WIRE_VARINT, bool(true)));
|
|
110
|
+
if (opts.jpegThumbnail) parts.push(field(16, WIRE_LEN, bytes(opts.jpegThumbnail)));
|
|
109
111
|
if (opts.contextInfo) parts.push(field(17, WIRE_LEN, encodeContextInfo(opts.contextInfo)));
|
|
110
112
|
return Buffer.concat(parts);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
function encodeAudioMessage(opts) {
|
|
116
|
+
// ptt (field 6) goes between seconds (5) and mediaKey (7) — must be in field-number order
|
|
117
|
+
const pttField = opts.ptt ? field(6, WIRE_VARINT, bool(true)) : Buffer.alloc(0);
|
|
114
118
|
const parts = [
|
|
115
119
|
field(1, WIRE_LEN, str(opts.url)),
|
|
116
120
|
field(2, WIRE_LEN, str(opts.mimetype || 'audio/ogg; codecs=opus')),
|
|
117
121
|
field(3, WIRE_LEN, bytes(opts.fileSha256)),
|
|
118
122
|
field(4, WIRE_VARINT, varint(opts.fileLength)),
|
|
119
123
|
field(5, WIRE_VARINT, varint(opts.seconds || 0)),
|
|
120
|
-
|
|
124
|
+
pttField,
|
|
121
125
|
field(7, WIRE_LEN, bytes(opts.mediaKey)),
|
|
122
126
|
field(8, WIRE_LEN, bytes(opts.fileEncSha256)),
|
|
123
127
|
field(9, WIRE_LEN, str(opts.directPath)),
|
package/lib/proto.js
CHANGED
|
@@ -43,7 +43,7 @@ function encodeUserAgent({ platform, version, mcc, mnc, osVersion, manufacturer,
|
|
|
43
43
|
];
|
|
44
44
|
if (osVersion) parts.push(field(5, LEN, string(osVersion)));
|
|
45
45
|
if (manufacturer) parts.push(field(6, LEN, string(manufacturer)));
|
|
46
|
-
if (device) parts.push(field(7, LEN, string(device)));
|
|
46
|
+
if (device) parts.push(field(7, LEN, string(String(device).replace(/_/g, ' '))));
|
|
47
47
|
if (osBuildNumber) parts.push(field(8, LEN, string(osBuildNumber)));
|
|
48
48
|
if (phoneId) parts.push(field(9, LEN, string(phoneId)));
|
|
49
49
|
parts.push(field(10, VARINT, varint(releaseChannel || 0)));
|
|
@@ -11,6 +11,31 @@ const PRE_KEY_COUNT = 100;
|
|
|
11
11
|
const PRE_KEY_MIN = 10;
|
|
12
12
|
const PRE_KEY_START = 1;
|
|
13
13
|
|
|
14
|
+
// ─── Per-JID async mutex (matches Baileys encryptionMutex pattern) ────────────
|
|
15
|
+
// Prevents race conditions when two messages are encrypted simultaneously
|
|
16
|
+
// for the same Signal session (e.g. rapid sends or parallel fanout calls).
|
|
17
|
+
|
|
18
|
+
const _mutexQueues = new Map();
|
|
19
|
+
|
|
20
|
+
async function _withMutex(key, fn) {
|
|
21
|
+
// If no queue exists for this key, run immediately
|
|
22
|
+
if (!_mutexQueues.has(key)) {
|
|
23
|
+
_mutexQueues.set(key, Promise.resolve());
|
|
24
|
+
}
|
|
25
|
+
const prev = _mutexQueues.get(key);
|
|
26
|
+
let releasePrev;
|
|
27
|
+
const next = new Promise(r => { releasePrev = r; });
|
|
28
|
+
_mutexQueues.set(key, next);
|
|
29
|
+
await prev;
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
releasePrev();
|
|
34
|
+
// Cleanup: if no more waiters, remove from map
|
|
35
|
+
if (_mutexQueues.get(key) === next) _mutexQueues.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
// ─── Padding ──────────────────────────────────────────────────────────────────
|
|
15
40
|
|
|
16
41
|
function randomPadding() {
|
|
@@ -159,14 +184,17 @@ class SignalProtocol {
|
|
|
159
184
|
// ─── 1-to-1 encrypt / decrypt ─────────────────────────────────────────────
|
|
160
185
|
|
|
161
186
|
async encrypt(jid, plaintext) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
// Mutex per JID — prevents Signal session race conditions (matches Baileys encryptionMutex)
|
|
188
|
+
return _withMutex(jid, async () => {
|
|
189
|
+
const addr = jidToAddress(jid);
|
|
190
|
+
const cipher = new SessionCipher(this.store, addr);
|
|
191
|
+
const padded = pad(Buffer.from(plaintext));
|
|
192
|
+
const result = await cipher.encrypt(padded);
|
|
193
|
+
return {
|
|
194
|
+
type: result.type === 3 ? 'pkmsg' : 'msg',
|
|
195
|
+
ciphertext: Buffer.from(result.body, 'binary')
|
|
196
|
+
};
|
|
197
|
+
});
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
async decrypt(jid, type, ciphertext) {
|
|
@@ -182,18 +210,17 @@ class SignalProtocol {
|
|
|
182
210
|
}
|
|
183
211
|
|
|
184
212
|
// ─── Bulk encrypt for multiple devices (MD fanout) ─────────────────────────
|
|
213
|
+
// All devices encrypted in parallel (like Baileys Promise.all).
|
|
214
|
+
// Each JID is protected by its own mutex so parallel calls on the
|
|
215
|
+
// same JID are still serialized — no Signal session corruption possible.
|
|
185
216
|
// Returns [{jid, type, ciphertext}]
|
|
186
217
|
async bulkEncryptForDevices(jids, plaintext) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// Skip devices with no session or errors
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return results;
|
|
218
|
+
const settled = await Promise.allSettled(
|
|
219
|
+
jids.map(jid => this.encrypt(jid, plaintext).then(enc => ({ jid, type: enc.type, ciphertext: enc.ciphertext })))
|
|
220
|
+
);
|
|
221
|
+
return settled
|
|
222
|
+
.filter(r => r.status === 'fulfilled')
|
|
223
|
+
.map(r => r.value);
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
// ─── SenderKey group encrypt / decrypt ────────────────────────────────────
|