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 +82 -3
- package/lib/signal/SenderKey.js +116 -15
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
.
|
|
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
|
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
}
|
|
282
|
+
});
|
|
182
283
|
}
|
|
183
284
|
|
|
184
285
|
_key(groupId, senderId) {
|