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.
@@ -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 }) =>
@@ -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, 'text', options);
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
- return this._sendGroupMessage(toJid, msgId, plaintext, mediaType);
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 = allTargets.length > 0 ? computePhash(allTargets) : null;
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 || 'User',
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 >>> 0;
14
+ let val = Math.floor(n);
13
15
  while (val > 127) {
14
- bytes.push((val & 0x7f) | 0x80);
15
- val = val >>> 7;
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(12, WIRE_LEN, str(opts.directPath)),
105
- field(13, WIRE_VARINT, varintS(opts.mediaKeyTimestamp || Math.floor(Date.now() / 1000)))
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
- field(6, WIRE_VARINT, bool(opts.ptt || false)),
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
- const addr = jidToAddress(jid);
163
- const cipher = new SessionCipher(this.store, addr);
164
- const padded = pad(Buffer.from(plaintext));
165
- const result = await cipher.encrypt(padded);
166
- return {
167
- type: result.type === 3 ? 'pkmsg' : 'msg',
168
- ciphertext: Buffer.from(result.body, 'binary')
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 results = [];
188
- for (const jid of jids) {
189
- try {
190
- const enc = await this.encrypt(jid, plaintext);
191
- results.push({ jid, type: enc.type, ciphertext: enc.ciphertext });
192
- } catch (err) {
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 ────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "2.1.1",
3
+ "version": "3.3.1",
4
4
  "description": "WhatsApp mobile-only Node.js client library with Signal Protocol encryption",
5
5
  "main": "index.js",
6
6
  "bin": {