whalibmob 5.14.16 → 5.14.18

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/Client.js CHANGED
@@ -2327,6 +2327,56 @@ class WhalibmobClient extends EventEmitter {
2327
2327
  return out;
2328
2328
  }
2329
2329
 
2330
+ // Every address a group participant might be filed under.
2331
+ //
2332
+ // Deliberately not _signalJidCandidates: that one starts from the stanza's
2333
+ // `from`, which in a group is the group itself and is not a Signal address at
2334
+ // all. Here the sender is the only subject, under both of the names the same
2335
+ // person answers to.
2336
+ _senderAddressCandidates(senderJid, senderPn) {
2337
+ const out = [];
2338
+ const add = (jid) => {
2339
+ if (!jid) return;
2340
+ const str = String(jid);
2341
+ if (str && !out.includes(str)) out.push(str);
2342
+ };
2343
+ add(senderJid);
2344
+ add(this._counterpartJid(senderJid));
2345
+ add(senderPn);
2346
+ add(this._counterpartJid(senderPn));
2347
+ return out;
2348
+ }
2349
+
2350
+ // The sender key for a group message, under whichever name it was filed.
2351
+ //
2352
+ // A SenderKeyName keeps the user and the device and drops the domain, so the
2353
+ // LID form and the phone form of one person are two different names for one
2354
+ // key. Which of them a message arrives under is the server's choice and it
2355
+ // varies, so a key stored under the name one stanza used is invisible to the
2356
+ // next stanza that names the other. That is the whole of "No session found to
2357
+ // decrypt message" on a group we have only just joined.
2358
+ async _senderKeyDecryptWithCandidates(groupJid, candidates, cipherBuf, id) {
2359
+ let firstErr = null;
2360
+ for (let i = 0; i < candidates.length; i++) {
2361
+ try {
2362
+ const plaintext = await this._signal.senderKeyDecrypt(groupJid, candidates[i], cipherBuf);
2363
+ if (i > 0) {
2364
+ _whaDbg('[DBG] GROUP_DECRYPT id=' + id + ' recovered under ' + candidates[i] +
2365
+ ' after ' + candidates[0] + ' held no sender key');
2366
+ }
2367
+ return plaintext;
2368
+ } catch (err) {
2369
+ if (!firstErr) firstErr = err;
2370
+ // Only "we hold nothing under this name" is worth asking another name
2371
+ // about. A record that exists and refuses the message is a different
2372
+ // problem, and the answer to that is a retry receipt, not another
2373
+ // lookup.
2374
+ if (!/no session found|no senderkeyrecord/i.test(String(err && err.message))) break;
2375
+ }
2376
+ }
2377
+ throw firstErr || new Error('senderKeyDecrypt: no address to try');
2378
+ }
2379
+
2330
2380
  // Try each address in turn and return the first plaintext.
2331
2381
  //
2332
2382
  // A failed attempt leaves nothing behind — libsignal either returns the
@@ -2347,10 +2397,21 @@ class WhalibmobClient extends EventEmitter {
2347
2397
  return plaintext;
2348
2398
  } catch (err) {
2349
2399
  if (!firstErr) firstErr = err;
2350
- // Only a missing session is worth asking a different address about.
2351
- // A session that exists and refuses the ciphertext is a different
2352
- // problem, and the answer is a retry receipt, not another lookup.
2353
- if (!/no matching sessions|no session/i.test(String(err && err.message))) break;
2400
+ // Only "this is not where the session lives" is worth asking a
2401
+ // different address about. A session that exists and refuses the
2402
+ // ciphertext is a different problem, and the answer to that is a retry
2403
+ // receipt, not another lookup.
2404
+ //
2405
+ // "Invalid PreKey ID" belongs in the first group, which is not obvious.
2406
+ // A sender keeps sending pkmsg until it hears back, and every one of
2407
+ // them names the same pre-key — so once the first has been opened, that
2408
+ // pre-key is spent. Meeting one under an address that holds no session
2409
+ // is therefore not a mismatched key: it is this address having no
2410
+ // record of the session the pre-key was already spent on, which is
2411
+ // exactly the case for looking under the other name. Without it the
2412
+ // search stopped at the first address and a group we had just joined
2413
+ // stayed unreadable — which is how this was found.
2414
+ if (!/no matching sessions|no session|invalid prekey/i.test(String(err && err.message))) break;
2354
2415
  }
2355
2416
  }
2356
2417
 
@@ -2458,8 +2519,13 @@ class WhalibmobClient extends EventEmitter {
2458
2519
  ? skmsgNode.content
2459
2520
  : Buffer.from(skmsgNode.content || '');
2460
2521
 
2522
+ const senderCandidates = this._senderAddressCandidates(participant);
2523
+ _whaDbg('[DBG] GROUP_DECRYPT id=' + id + ' group=' + from + ' sender=' + participant +
2524
+ (senderCandidates.length > 1 ? ' alts=' + senderCandidates.slice(1).join(',') : '') +
2525
+ ' cipherLen=' + cipherBuf.length);
2526
+
2461
2527
  // Returned for the same reason as the direct-message path above.
2462
- return this._signal.senderKeyDecrypt(from, participant, cipherBuf)
2528
+ return this._senderKeyDecryptWithCandidates(from, senderCandidates, cipherBuf, id)
2463
2529
  .then(plaintext => {
2464
2530
  this._resolveRetry(id);
2465
2531
  const decoded = this._decodeMsg(plaintext);
@@ -2525,19 +2591,43 @@ class WhalibmobClient extends EventEmitter {
2525
2591
  const encType = enc.attrs && enc.attrs.type;
2526
2592
  const cipherBuf = Buffer.isBuffer(enc.content) ? enc.content : Buffer.from(enc.content || '');
2527
2593
 
2594
+ // The sender key rides inside a 1-to-1 message, so opening it needs the
2595
+ // session with that participant — and the session may be filed under the
2596
+ // other name they answer to. Addressing only the name this stanza used is
2597
+ // how joining a group ended with every message failing: the distribution
2598
+ // could not be opened, so no sender key was ever stored, so every skmsg
2599
+ // after it had nothing to decrypt with.
2600
+ const senderCandidates = this._senderAddressCandidates(senderJid);
2601
+
2528
2602
  tasks.push(
2529
- this._signal.decrypt(senderJid, encType, cipherBuf)
2603
+ this._decryptWithCandidates(senderCandidates, encType, cipherBuf, 'skdm')
2530
2604
  .then(async decrypted => {
2531
2605
  const decoded = this._decodeMsg(decrypted);
2606
+ // Filed under the name this stanza used, which is the name the
2607
+ // group message that follows will look it up under.
2532
2608
  if (decoded && decoded.type === 'senderKeyDistribution' &&
2533
2609
  decoded.groupId && decoded.axolotlBytes) {
2534
2610
  await this._signal.processSKDM(decoded.groupId, senderJid, decoded.axolotlBytes);
2611
+ _whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.groupId + ' sender=' + senderJid);
2535
2612
  } else if (decoded && decoded.skdm &&
2536
2613
  decoded.skdm.groupId && decoded.skdm.axolotlBytes) {
2537
2614
  await this._signal.processSKDM(decoded.skdm.groupId, senderJid, decoded.skdm.axolotlBytes);
2615
+ _whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.skdm.groupId + ' sender=' + senderJid);
2616
+ } else {
2617
+ _whaDbg('[DBG] SKDM_DIST_NONE sender=' + senderJid +
2618
+ ' — the <to> entry for us carried no distribution');
2538
2619
  }
2539
2620
  })
2540
- .catch(() => {})
2621
+ // Said out loud rather than swallowed. This failing is the reason the
2622
+ // group messages that follow cannot be read, and it used to leave no
2623
+ // trace at all — only the downstream failures were visible, which
2624
+ // pointed at the wrong thing.
2625
+ .catch(err => {
2626
+ _whaDbg('[DBG] SKDM_DIST_ERR sender=' + senderJid +
2627
+ ' tried=[' + senderCandidates.join(',') + ']' +
2628
+ ' err=' + (err && err.message));
2629
+ this.emit('skdm_error', { group: groupJid, sender: senderJid, err });
2630
+ })
2541
2631
  );
2542
2632
  }
2543
2633
 
@@ -95,6 +95,15 @@ class SenderKeyStore {
95
95
  this._dirty = false;
96
96
  this._rev = 0;
97
97
  this._saveTimer = null;
98
+ // Same three as in SignalStore, for the same reason. The debounced write
99
+ // here was never cancelled or awaited either, so a synchronous flush could
100
+ // be overtaken by an older one still in flight — and this file is where the
101
+ // group sender keys live, including the record of who has already been sent
102
+ // ours. _writtenRev is what the file holds; _writing keeps the async writes
103
+ // to one at a time; _writeAgain remembers one that came due mid-write.
104
+ this._writtenRev = 0;
105
+ this._writing = false;
106
+ this._writeAgain = false;
98
107
 
99
108
  // Detachable, for the same reason as in SignalStore: a replaced store must
100
109
  // not keep three process listeners alive for the rest of the run.
@@ -115,7 +124,14 @@ class SenderKeyStore {
115
124
  process.removeListener('SIGTERM', this._onSigTerm);
116
125
  process.removeListener('SIGINT', this._onSigInt);
117
126
  } catch (_) {}
118
- this._filePath = null;
127
+ // Clearing the path also tells a write still in flight to stand down — it
128
+ // checks the path it started with before renaming. The caller detaches in
129
+ // order to move these files, so leave nothing of ours in the directory it
130
+ // is about to rename.
131
+ const stale = this._filePath ? [this._tmpPath(true), this._tmpPath(false)] : [];
132
+ this._filePath = null;
133
+ this._writeAgain = false;
134
+ for (const f of stale) { try { fs.unlinkSync(f); } catch (_) {} }
119
135
  }
120
136
 
121
137
  attachFile(filePath) {
@@ -150,14 +166,52 @@ class SenderKeyStore {
150
166
  }
151
167
  }
152
168
 
169
+ // Everything that is persisted, as it goes on disk.
170
+ _snapshot() {
171
+ return JSON.stringify(this._data, null, 2);
172
+ }
173
+
174
+ // Where a write assembles the file before it becomes the file. The sync and
175
+ // async writers get separate names so neither can find the other's
176
+ // half-written temp file under it.
177
+ _tmpPath(sync) {
178
+ return this._filePath + (sync ? '.stmp' : '.atmp');
179
+ }
180
+
181
+ // A rename replaces the file, so the one that lands carries the temp file's
182
+ // permissions and not the old file's. This holds group signing keys, and an
183
+ // operator who tightened its mode would find it loosened again by the next
184
+ // write — so carry across whatever the file already had.
185
+ _modeOf(file) {
186
+ try { return fs.statSync(file).mode & 0o777; } catch (_) { return null; }
187
+ }
188
+
153
189
  _flushSync() {
154
190
  if (!this._dirty || !this._filePath) return;
155
191
  if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
192
+ const rev = this._rev;
193
+ const file = this._filePath;
194
+ const tmp = this._tmpPath(true);
156
195
  try {
157
- fs.mkdirSync(path.dirname(this._filePath), { recursive: true });
158
- fs.writeFileSync(this._filePath, JSON.stringify(this._data, null, 2));
159
- this._dirty = false;
160
- } catch (_) {}
196
+ fs.mkdirSync(path.dirname(file), { recursive: true });
197
+ const mode = this._modeOf(file);
198
+ fs.writeFileSync(tmp, this._snapshot(), mode !== null
199
+ ? { encoding: 'utf8', mode } : 'utf8');
200
+ if (mode !== null) fs.chmodSync(tmp, mode);
201
+ fs.renameSync(tmp, file);
202
+ // Take the async writer's temp file away from it. Its "have I been
203
+ // overtaken?" check runs on this thread, but the rename it authorises is
204
+ // carried out on a pool thread afterwards — and at exit the pool is
205
+ // usually busy, so that rename can still be queued now and execute during
206
+ // teardown, putting this snapshot back to an older one. Its callback
207
+ // never runs there, so nothing would notice. Removing the source makes
208
+ // that queued rename fail ENOENT instead.
209
+ try { fs.unlinkSync(this._tmpPath(false)); } catch (_) {}
210
+ this._writtenRev = rev;
211
+ this._dirty = false;
212
+ } catch (_) {
213
+ try { fs.unlinkSync(tmp); } catch (_) {}
214
+ }
161
215
  }
162
216
 
163
217
  _save() {
@@ -167,18 +221,65 @@ class SenderKeyStore {
167
221
  if (this._saveTimer) clearTimeout(this._saveTimer);
168
222
  this._saveTimer = setTimeout(() => {
169
223
  this._saveTimer = null;
170
- // The revision this write is a snapshot of.
171
- const rev = this._rev;
172
- const data = JSON.stringify(this._data, null, 2);
173
- fs.mkdirSync(path.dirname(this._filePath), { recursive: true });
174
- // Only clear the flag when nothing changed while the write was running.
175
- // A mutation that arrived after the snapshot is not in `data`, and its
176
- // own debounce timer may never fire — clearing here would make the
177
- // exit-time _flushSync() skip it and lose it.
178
- fs.writeFile(this._filePath, data, 'utf8', () => {
224
+ this._writeAsync();
225
+ }, _SK_DEBOUNCE_MS);
226
+ }
227
+
228
+ // The debounced write itself: temp file, then rename into place, and only if
229
+ // nothing newer reached the disk in the meantime.
230
+ _writeAsync() {
231
+ if (!this._filePath || !this._dirty) return;
232
+ // Fold a write that comes due mid-write into the one already running rather
233
+ // than starting a second one over the same temp file.
234
+ if (this._writing) { this._writeAgain = true; return; }
235
+
236
+ // Read once, here: by the time the callbacks run either may have moved on,
237
+ // and that is exactly what they have to notice.
238
+ const rev = this._rev;
239
+ const file = this._filePath;
240
+ const tmp = this._tmpPath(false);
241
+
242
+ let data, mode;
243
+ try {
244
+ fs.mkdirSync(path.dirname(file), { recursive: true });
245
+ mode = this._modeOf(file);
246
+ data = this._snapshot();
247
+ } catch (_) { return; }
248
+
249
+ this._writing = true;
250
+ this._writeAgain = false;
251
+
252
+ const finish = () => {
253
+ this._writing = false;
254
+ // A write came due while this one was running and was folded into it.
255
+ // Every other mutation armed its own debounce timer, which is still
256
+ // pending and will write what is current.
257
+ if (this._writeAgain && this._dirty && this._filePath) this._writeAsync();
258
+ };
259
+ const abandon = () => { fs.unlink(tmp, () => {}); finish(); };
260
+
261
+ // Created already tight rather than widening the window in which a file of
262
+ // signing keys sits at the umask default.
263
+ fs.writeFile(tmp, data, mode !== null ? { encoding: 'utf8', mode } : 'utf8', (err) => {
264
+ if (err) return abandon();
265
+ // detach() let the file go, or a synchronous flush wrote newer state
266
+ // while this one was running. Renaming now would put an older snapshot
267
+ // back and take every sender key written since with it.
268
+ if (file !== this._filePath || rev <= this._writtenRev) return abandon();
269
+ if (mode !== null) { try { fs.chmodSync(tmp, mode); } catch (_) {} }
270
+ fs.rename(tmp, file, (renameErr) => {
271
+ if (renameErr) return abandon();
272
+ // Never walk it backwards: a synchronous flush can land — and record a
273
+ // higher revision — between the check above and this callback.
274
+ if (rev > this._writtenRev) this._writtenRev = rev;
275
+ // Only clear the flag when nothing changed while the write was running.
276
+ // A mutation that arrived after the snapshot is not in `data`, and
277
+ // clearing here would make the exit-time _flushSync() skip it and lose
278
+ // it.
179
279
  if (this._rev === rev) this._dirty = false;
280
+ finish();
180
281
  });
181
- }, _SK_DEBOUNCE_MS);
282
+ });
182
283
  }
183
284
 
184
285
  _key(groupId, senderId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.14.16",
3
+ "version": "5.14.18",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",