whalibmob 5.14.16 → 5.14.17

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
@@ -2458,8 +2508,13 @@ class WhalibmobClient extends EventEmitter {
2458
2508
  ? skmsgNode.content
2459
2509
  : Buffer.from(skmsgNode.content || '');
2460
2510
 
2511
+ const senderCandidates = this._senderAddressCandidates(participant);
2512
+ _whaDbg('[DBG] GROUP_DECRYPT id=' + id + ' group=' + from + ' sender=' + participant +
2513
+ (senderCandidates.length > 1 ? ' alts=' + senderCandidates.slice(1).join(',') : '') +
2514
+ ' cipherLen=' + cipherBuf.length);
2515
+
2461
2516
  // Returned for the same reason as the direct-message path above.
2462
- return this._signal.senderKeyDecrypt(from, participant, cipherBuf)
2517
+ return this._senderKeyDecryptWithCandidates(from, senderCandidates, cipherBuf, id)
2463
2518
  .then(plaintext => {
2464
2519
  this._resolveRetry(id);
2465
2520
  const decoded = this._decodeMsg(plaintext);
@@ -2525,19 +2580,43 @@ class WhalibmobClient extends EventEmitter {
2525
2580
  const encType = enc.attrs && enc.attrs.type;
2526
2581
  const cipherBuf = Buffer.isBuffer(enc.content) ? enc.content : Buffer.from(enc.content || '');
2527
2582
 
2583
+ // The sender key rides inside a 1-to-1 message, so opening it needs the
2584
+ // session with that participant — and the session may be filed under the
2585
+ // other name they answer to. Addressing only the name this stanza used is
2586
+ // how joining a group ended with every message failing: the distribution
2587
+ // could not be opened, so no sender key was ever stored, so every skmsg
2588
+ // after it had nothing to decrypt with.
2589
+ const senderCandidates = this._senderAddressCandidates(senderJid);
2590
+
2528
2591
  tasks.push(
2529
- this._signal.decrypt(senderJid, encType, cipherBuf)
2592
+ this._decryptWithCandidates(senderCandidates, encType, cipherBuf, 'skdm')
2530
2593
  .then(async decrypted => {
2531
2594
  const decoded = this._decodeMsg(decrypted);
2595
+ // Filed under the name this stanza used, which is the name the
2596
+ // group message that follows will look it up under.
2532
2597
  if (decoded && decoded.type === 'senderKeyDistribution' &&
2533
2598
  decoded.groupId && decoded.axolotlBytes) {
2534
2599
  await this._signal.processSKDM(decoded.groupId, senderJid, decoded.axolotlBytes);
2600
+ _whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.groupId + ' sender=' + senderJid);
2535
2601
  } else if (decoded && decoded.skdm &&
2536
2602
  decoded.skdm.groupId && decoded.skdm.axolotlBytes) {
2537
2603
  await this._signal.processSKDM(decoded.skdm.groupId, senderJid, decoded.skdm.axolotlBytes);
2604
+ _whaDbg('[DBG] SKDM_DIST_STORED group=' + decoded.skdm.groupId + ' sender=' + senderJid);
2605
+ } else {
2606
+ _whaDbg('[DBG] SKDM_DIST_NONE sender=' + senderJid +
2607
+ ' — the <to> entry for us carried no distribution');
2538
2608
  }
2539
2609
  })
2540
- .catch(() => {})
2610
+ // Said out loud rather than swallowed. This failing is the reason the
2611
+ // group messages that follow cannot be read, and it used to leave no
2612
+ // trace at all — only the downstream failures were visible, which
2613
+ // pointed at the wrong thing.
2614
+ .catch(err => {
2615
+ _whaDbg('[DBG] SKDM_DIST_ERR sender=' + senderJid +
2616
+ ' tried=[' + senderCandidates.join(',') + ']' +
2617
+ ' err=' + (err && err.message));
2618
+ this.emit('skdm_error', { group: groupJid, sender: senderJid, err });
2619
+ })
2541
2620
  );
2542
2621
  }
2543
2622
 
@@ -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.17",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",