whalibmob 5.14.8 → 5.14.11
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 +36 -8
- package/lib/Registration.js +2 -2
- package/lib/image/Bmp.js +16 -4
- package/lib/signal/SignalStore.js +152 -29
- package/package.json +1 -1
package/lib/Client.js
CHANGED
|
@@ -442,9 +442,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
442
442
|
async init(phoneNumber) {
|
|
443
443
|
phoneNumber = String(phoneNumber).replace(/\D/g, '');
|
|
444
444
|
this._phoneNumber = phoneNumber;
|
|
445
|
-
// A fresh attempt clears whatever made the last one fatal
|
|
446
|
-
//
|
|
447
|
-
|
|
445
|
+
// A fresh attempt clears whatever made the last one fatal, and whatever
|
|
446
|
+
// deliberate disconnect came before it. Only the public entry points reset
|
|
447
|
+
// these — the reconnect loop must not clear its own guards.
|
|
448
|
+
this._fatal = false;
|
|
449
|
+
this._closing = false;
|
|
448
450
|
|
|
449
451
|
this._sessionDir = sessionDirFor(this._baseDir, phoneNumber);
|
|
450
452
|
|
|
@@ -621,7 +623,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
621
623
|
phoneNumber = String(phoneNumber).replace(/\D/g, '');
|
|
622
624
|
this._phoneNumber = phoneNumber;
|
|
623
625
|
this._mode = 'web';
|
|
624
|
-
|
|
626
|
+
// Same as init(): a fresh attempt lifts both guards from the last one.
|
|
627
|
+
this._fatal = false;
|
|
628
|
+
this._closing = false;
|
|
625
629
|
|
|
626
630
|
const { createNewWebStore, loadWebStore, saveWebStore, webSessionPath } =
|
|
627
631
|
require('./WebStore');
|
|
@@ -1066,13 +1070,34 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1066
1070
|
return idx > 0 ? `${lidUser}:${idx}@lid` : `${lidUser}@lid`;
|
|
1067
1071
|
}
|
|
1068
1072
|
|
|
1069
|
-
|
|
1073
|
+
/**
|
|
1074
|
+
* Close the connection, and keep it closed.
|
|
1075
|
+
*
|
|
1076
|
+
* Connect again through init() / initWeb() — those are where the caller says
|
|
1077
|
+
* it wants a connection, and where the guard below is lifted.
|
|
1078
|
+
*
|
|
1079
|
+
* @param {object} [opts]
|
|
1080
|
+
* @param {boolean} [opts.reconnect=false] drop the current socket but leave
|
|
1081
|
+
* the reconnect loop armed. For a failure the server has told us is
|
|
1082
|
+
* temporary, where coming back is the right answer.
|
|
1083
|
+
*/
|
|
1084
|
+
disconnect(opts) {
|
|
1070
1085
|
// Closing the socket makes it emit 'close', which lands in _onClose and
|
|
1071
1086
|
// schedules a reconnect. That is right for a dropped connection and wrong
|
|
1072
1087
|
// for a deliberate teardown — and it is what turned a single <failure> into
|
|
1073
1088
|
// an endless retry loop, since clearing _reconnecting was not enough to
|
|
1074
1089
|
// stop the handler that sets it again.
|
|
1075
|
-
|
|
1090
|
+
//
|
|
1091
|
+
// For that to work the guard has to still be set when the 'close' arrives,
|
|
1092
|
+
// and it arrives on a later tick — a socket is destroyed now and reports it
|
|
1093
|
+
// afterwards. Clearing it at the end of this method, which is what it used
|
|
1094
|
+
// to do, meant _scheduleReconnect() never once saw it set: every deliberate
|
|
1095
|
+
// disconnect dialled straight back in a second later, and /reconnect ended
|
|
1096
|
+
// up with the old session live alongside the new one.
|
|
1097
|
+
//
|
|
1098
|
+
// Only ever set here, never cleared, so a teardown that means it cannot be
|
|
1099
|
+
// undone by a later one that does not.
|
|
1100
|
+
if (!(opts && opts.reconnect)) this._closing = true;
|
|
1076
1101
|
this._reconnecting = false;
|
|
1077
1102
|
this._reconnectTry = 0;
|
|
1078
1103
|
this._stopTimers();
|
|
@@ -1082,7 +1107,6 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1082
1107
|
}
|
|
1083
1108
|
this._connected = false;
|
|
1084
1109
|
this.emit('close');
|
|
1085
|
-
this._closing = false;
|
|
1086
1110
|
}
|
|
1087
1111
|
|
|
1088
1112
|
/**
|
|
@@ -3280,7 +3304,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3280
3304
|
this.emit('auth_failure', { reason, location: loc, loggedOut: spent, node });
|
|
3281
3305
|
this._fatal = spent;
|
|
3282
3306
|
this._reconnecting = false;
|
|
3283
|
-
this
|
|
3307
|
+
// A reason that did not kill the session is one to come back from, so this
|
|
3308
|
+
// teardown leaves the reconnect loop armed — a 503 is the server having a
|
|
3309
|
+
// bad minute, not a reason to abandon a working registration. A reason that
|
|
3310
|
+
// did kill it is fatal, and _scheduleReconnect() stands down on that itself.
|
|
3311
|
+
this.disconnect({ reconnect: !spent });
|
|
3284
3312
|
}
|
|
3285
3313
|
|
|
3286
3314
|
// ─── Media connection ─────────────────────────────────────────────────────
|
package/lib/Registration.js
CHANGED
|
@@ -482,7 +482,7 @@ const COUNTRY_META = {
|
|
|
482
482
|
'370': { mcc: '246', mnc: '01', lg: 'lt', lc: 'LT' }, // Lithuania (Telia)
|
|
483
483
|
'371': { mcc: '247', mnc: '01', lg: 'lv', lc: 'LV' }, // Latvia (LMT)
|
|
484
484
|
'372': { mcc: '248', mnc: '01', lg: 'et', lc: 'EE' }, // Estonia (Telia)
|
|
485
|
-
'373': { mcc: '
|
|
485
|
+
'373': { mcc: '259', mnc: '01', lg: 'ro', lc: 'MD' }, // Moldova (Orange)
|
|
486
486
|
'374': { mcc: '283', mnc: '01', lg: 'hy', lc: 'AM' }, // Armenia (Beeline)
|
|
487
487
|
'375': { mcc: '257', mnc: '01', lg: 'be', lc: 'BY' }, // Belarus (A1)
|
|
488
488
|
'376': { mcc: '213', mnc: '03', lg: 'ca', lc: 'AD' }, // Andorra (Som, Mobiland)
|
|
@@ -499,7 +499,7 @@ const COUNTRY_META = {
|
|
|
499
499
|
'423': { mcc: '295', mnc: '01', lg: 'de', lc: 'LI' }, // Liechtenstein (Swisscom)
|
|
500
500
|
'500': { mcc: '750', mnc: '001', lg: 'en', lc: 'FK' }, // Falkland Islands (Sure)
|
|
501
501
|
'501': { mcc: '702', mnc: '67', lg: 'en', lc: 'BZ' }, // Belize (DigiCell)
|
|
502
|
-
'502': { mcc: '704', mnc: '
|
|
502
|
+
'502': { mcc: '704', mnc: '01', lg: 'es', lc: 'GT' }, // Guatemala (Claro)
|
|
503
503
|
'503': { mcc: '706', mnc: '01', lg: 'es', lc: 'SV' }, // El Salvador (Claro)
|
|
504
504
|
'504': { mcc: '708', mnc: '001', lg: 'es', lc: 'HN' }, // Honduras (Claro)
|
|
505
505
|
'505': { mcc: '710', mnc: '21', lg: 'es', lc: 'NI' }, // Nicaragua (Claro)
|
package/lib/image/Bmp.js
CHANGED
|
@@ -13,15 +13,27 @@ function isBmp(buf) {
|
|
|
13
13
|
// One channel out of a packed pixel. A mask says where the channel sits and how
|
|
14
14
|
// wide it is, and the width varies — 5-6-5 and 5-5-5 are both ordinary — so the
|
|
15
15
|
// value is scaled by its own maximum rather than shifted by a fixed amount.
|
|
16
|
+
//
|
|
17
|
+
// Both scans below stop at 32 on their own, because a shift count in JS is
|
|
18
|
+
// taken mod 32: `bits >>> 32` is `bits >>> 0`, so a mask of all ones sends an
|
|
19
|
+
// unbounded scan back to bit 0 and it never comes out. Nothing upstream could
|
|
20
|
+
// catch that — decodeImage's try/catch cannot interrupt a loop that does not
|
|
21
|
+
// end, and a thumbnail is built on the way to sending an ordinary image, so
|
|
22
|
+
// one crafted BMP would take the whole process down with it.
|
|
16
23
|
function maskChannel(v, mask) {
|
|
17
24
|
if (!mask) return 0;
|
|
18
25
|
let shift = 0;
|
|
19
|
-
while (!((mask >>> shift) & 1)) shift++;
|
|
26
|
+
while (shift < 32 && !((mask >>> shift) & 1)) shift++;
|
|
27
|
+
if (shift === 32) return 0; // no bit set — nothing to read
|
|
20
28
|
const bits = (mask >>> shift) >>> 0;
|
|
21
29
|
let width = 0;
|
|
22
|
-
while ((bits >>> width) & 1) width++;
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
while (width < 32 && ((bits >>> width) & 1)) width++;
|
|
31
|
+
// The obvious `(1 << width) - 1` is wrong at both ends of that range: `1 <<
|
|
32
|
+
// 32` is 1, and `1 << 31` is negative, so a 31- or 32-bit channel would be
|
|
33
|
+
// scaled by zero or by a negative maximum. Build the maximum unsigned, and
|
|
34
|
+
// read the value back unsigned too — `& 0xffffffff` alone yields an int32.
|
|
35
|
+
const max = width >= 32 ? 0xffffffff : ((1 << width) >>> 0) - 1;
|
|
36
|
+
return Math.round((((v >>> shift) & max) >>> 0) * 255 / max);
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function decodeBmp(buf) {
|
|
@@ -14,6 +14,27 @@ const { SessionRecord } = require('./libsignal');
|
|
|
14
14
|
// • on process exit, flush synchronously (so state is never lost)
|
|
15
15
|
//
|
|
16
16
|
// Result: zero blocking disk I/O during the encryption hot-path.
|
|
17
|
+
//
|
|
18
|
+
// Two rules keep that from costing the account everything it knows:
|
|
19
|
+
//
|
|
20
|
+
// • Every write goes to a temp file and is renamed into place. Writing over
|
|
21
|
+
// the live path is not one step — it is an open, a truncate and as many
|
|
22
|
+
// writes as the file needs — and anything that reads or writes in the
|
|
23
|
+
// middle of that sees a mixture of two snapshots. That mixture is not
|
|
24
|
+
// valid JSON, and _load() below answers unreadable state with an empty
|
|
25
|
+
// store, so a single interleaved write silently costs the account every
|
|
26
|
+
// session, pre-key, identity and LID mapping it had. rename(2) is atomic:
|
|
27
|
+
// the file is one whole snapshot or the other, never a blend.
|
|
28
|
+
//
|
|
29
|
+
// • A write never lands if newer state reached the disk while it was in
|
|
30
|
+
// flight. The async write started by the debounce is not cancellable, and
|
|
31
|
+
// a synchronous flush — detach(), or the exit handlers — can easily
|
|
32
|
+
// overtake it; letting the older snapshot finish last would undo every
|
|
33
|
+
// session written since. _writtenRev records what the file holds, and a
|
|
34
|
+
// write that has been overtaken drops its temp file instead of renaming.
|
|
35
|
+
//
|
|
36
|
+
// In-memory state is always the newest thing there is, so any write we abandon
|
|
37
|
+
// costs nothing: the next one writes what is current.
|
|
17
38
|
|
|
18
39
|
const DEBOUNCE_MS = 300;
|
|
19
40
|
|
|
@@ -37,6 +58,15 @@ class SignalStore {
|
|
|
37
58
|
this._dirty = false;
|
|
38
59
|
this._rev = 0;
|
|
39
60
|
this._saveTimer = null;
|
|
61
|
+
// The revision the file on disk actually holds. An async write compares
|
|
62
|
+
// against it before renaming: if a synchronous flush got there first, the
|
|
63
|
+
// write it is holding is stale and must not land.
|
|
64
|
+
this._writtenRev = 0;
|
|
65
|
+
// One async write at a time. Two of them running together on one path
|
|
66
|
+
// interleave, and _writeAgain remembers that another one came due while
|
|
67
|
+
// the first was still going so it can be re-run from a fresh snapshot.
|
|
68
|
+
this._writing = false;
|
|
69
|
+
this._writeAgain = false;
|
|
40
70
|
|
|
41
71
|
// Flush synchronously on exit so no state is ever lost.
|
|
42
72
|
//
|
|
@@ -61,7 +91,14 @@ class SignalStore {
|
|
|
61
91
|
process.removeListener('SIGTERM', this._onSigTerm);
|
|
62
92
|
process.removeListener('SIGINT', this._onSigInt);
|
|
63
93
|
} catch (_) {}
|
|
64
|
-
|
|
94
|
+
// Clearing the path is also what tells a write that is still in flight to
|
|
95
|
+
// stand down — it checks the path it started with before renaming. The
|
|
96
|
+
// caller detaches in order to move these files, so leave nothing of ours
|
|
97
|
+
// behind in the directory it is about to rename.
|
|
98
|
+
const stale = this._filePath ? [this._tmpPath(true), this._tmpPath(false)] : [];
|
|
99
|
+
this._filePath = null;
|
|
100
|
+
this._writeAgain = false;
|
|
101
|
+
for (const f of stale) { try { fs.unlinkSync(f); } catch (_) {} }
|
|
65
102
|
}
|
|
66
103
|
|
|
67
104
|
init(identityKeyPair, registrationId) {
|
|
@@ -100,23 +137,67 @@ class SignalStore {
|
|
|
100
137
|
return this._lidMappings;
|
|
101
138
|
}
|
|
102
139
|
|
|
103
|
-
//
|
|
140
|
+
// Everything that is persisted, as it goes on disk.
|
|
141
|
+
_snapshot() {
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
sessions: this._sessions,
|
|
144
|
+
preKeys: this._preKeys,
|
|
145
|
+
signedPreKeys: this._signedPreKeys,
|
|
146
|
+
identities: this._identities,
|
|
147
|
+
lidMappings: this._lidMappings
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Where a write assembles the file before it becomes the file. The sync and
|
|
152
|
+
// async writers get separate names so the one can never find the other's
|
|
153
|
+
// half-written temp file under it.
|
|
154
|
+
_tmpPath(sync) {
|
|
155
|
+
return this._filePath + (sync ? '.stmp' : '.atmp');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A rename replaces the file, so the one that lands carries the temp file's
|
|
159
|
+
// permissions and not the old file's. This holds Signal private keys, and an
|
|
160
|
+
// operator who tightened its mode would find it loosened again by the next
|
|
161
|
+
// write — so carry whatever the file already had across. null when there is
|
|
162
|
+
// no file yet, which leaves the default the old writes produced.
|
|
163
|
+
_modeOf(file) {
|
|
164
|
+
try { return fs.statSync(file).mode & 0o777; } catch (_) { return null; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Immediate synchronous flush (process exit, and detach())
|
|
104
168
|
_flushSync() {
|
|
105
169
|
if (!this._dirty || !this._filePath) return;
|
|
106
170
|
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
171
|
+
const rev = this._rev;
|
|
172
|
+
const file = this._filePath;
|
|
173
|
+
const tmp = this._tmpPath(true);
|
|
107
174
|
try {
|
|
108
|
-
const
|
|
109
|
-
sessions: this._sessions,
|
|
110
|
-
preKeys: this._preKeys,
|
|
111
|
-
signedPreKeys: this._signedPreKeys,
|
|
112
|
-
identities: this._identities,
|
|
113
|
-
lidMappings: this._lidMappings
|
|
114
|
-
});
|
|
115
|
-
const dir = path.dirname(this._filePath);
|
|
175
|
+
const dir = path.dirname(file);
|
|
116
176
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
|
|
177
|
+
const mode = this._modeOf(file);
|
|
178
|
+
fs.writeFileSync(tmp, this._snapshot(), mode !== null
|
|
179
|
+
? { encoding: 'utf8', mode } : 'utf8');
|
|
180
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
181
|
+
fs.renameSync(tmp, file);
|
|
182
|
+
// Take the async writer's temp file away from it.
|
|
183
|
+
//
|
|
184
|
+
// Its "have I been overtaken?" check runs on this thread, but the rename
|
|
185
|
+
// it authorises is carried out on a pool thread afterwards — and at exit
|
|
186
|
+
// the pool is usually busy, so that rename can still be queued now and
|
|
187
|
+
// execute during teardown, putting this snapshot back to an older one.
|
|
188
|
+
// Its callback never runs there, so nothing would notice or repair it.
|
|
189
|
+
// Removing the source makes that queued rename fail ENOENT instead.
|
|
190
|
+
// detach() does the same thing, which is why detach() was never exposed
|
|
191
|
+
// to this; the exit path simply was not doing it.
|
|
192
|
+
try { fs.unlinkSync(this._tmpPath(false)); } catch (_) {}
|
|
193
|
+
// Nothing ran between the snapshot and the rename — this is synchronous —
|
|
194
|
+
// so the file now holds exactly this revision and there is nothing left
|
|
195
|
+
// to write.
|
|
196
|
+
this._writtenRev = rev;
|
|
197
|
+
this._dirty = false;
|
|
198
|
+
} catch (_) {
|
|
199
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
200
|
+
}
|
|
120
201
|
}
|
|
121
202
|
|
|
122
203
|
// Schedule an async (non-blocking) write after DEBOUNCE_MS inactivity.
|
|
@@ -128,25 +209,67 @@ class SignalStore {
|
|
|
128
209
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
129
210
|
this._saveTimer = setTimeout(() => {
|
|
130
211
|
this._saveTimer = null;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
212
|
+
this._writeAsync();
|
|
213
|
+
}, DEBOUNCE_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The debounced write itself: temp file, then rename into place, and only if
|
|
217
|
+
// nothing newer reached the disk in the meantime.
|
|
218
|
+
_writeAsync() {
|
|
219
|
+
if (!this._filePath || !this._dirty) return;
|
|
220
|
+
// Fold a write that comes due mid-write into the one already running
|
|
221
|
+
// rather than starting a second one over the same temp file.
|
|
222
|
+
if (this._writing) { this._writeAgain = true; return; }
|
|
223
|
+
|
|
224
|
+
// The revision this write is a snapshot of, and the path it is for. Both
|
|
225
|
+
// are read once, here: by the time the callbacks run either may have moved
|
|
226
|
+
// on, and that is exactly what they have to notice.
|
|
227
|
+
const rev = this._rev;
|
|
228
|
+
const file = this._filePath;
|
|
229
|
+
const tmp = this._tmpPath(false);
|
|
230
|
+
|
|
231
|
+
let data, mode;
|
|
232
|
+
try {
|
|
233
|
+
const dir = path.dirname(file);
|
|
141
234
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
235
|
+
mode = this._modeOf(file);
|
|
236
|
+
data = this._snapshot();
|
|
237
|
+
} catch (_) { return; }
|
|
238
|
+
|
|
239
|
+
this._writing = true;
|
|
240
|
+
this._writeAgain = false;
|
|
241
|
+
|
|
242
|
+
const finish = () => {
|
|
243
|
+
this._writing = false;
|
|
244
|
+
// A write came due while this one was running and was folded into it.
|
|
245
|
+
// Every other mutation armed its own debounce timer, which is still
|
|
246
|
+
// pending and will write what is current.
|
|
247
|
+
if (this._writeAgain && this._dirty && this._filePath) this._writeAsync();
|
|
248
|
+
};
|
|
249
|
+
const abandon = () => { fs.unlink(tmp, () => {}); finish(); };
|
|
250
|
+
|
|
251
|
+
// Create the temp file already tight rather than widening the window in
|
|
252
|
+
// which a store full of private keys sits at the umask default.
|
|
253
|
+
fs.writeFile(tmp, data, mode !== null ? { encoding: 'utf8', mode } : 'utf8', (err) => {
|
|
254
|
+
if (err) return abandon();
|
|
255
|
+
// detach() let the file go, or a synchronous flush wrote newer state
|
|
256
|
+
// while this one was running. Renaming now would put an older snapshot
|
|
257
|
+
// back and take every session written since with it.
|
|
258
|
+
if (file !== this._filePath || rev <= this._writtenRev) return abandon();
|
|
259
|
+
if (mode !== null) { try { fs.chmodSync(tmp, mode); } catch (_) {} }
|
|
260
|
+
fs.rename(tmp, file, (renameErr) => {
|
|
261
|
+
if (renameErr) return abandon();
|
|
262
|
+
// Never walk it backwards: a synchronous flush can land — and record a
|
|
263
|
+
// higher revision — between the check above and this callback.
|
|
264
|
+
if (rev > this._writtenRev) this._writtenRev = rev;
|
|
265
|
+
// Only clear the flag when nothing changed while the write was
|
|
266
|
+
// running. A mutation that arrived after the snapshot is not in
|
|
267
|
+
// `data`, and clearing here would make the exit-time _flushSync()
|
|
268
|
+
// skip it and lose it.
|
|
147
269
|
if (this._rev === rev) this._dirty = false;
|
|
270
|
+
finish();
|
|
148
271
|
});
|
|
149
|
-
}
|
|
272
|
+
});
|
|
150
273
|
}
|
|
151
274
|
|
|
152
275
|
// ─── Identity / registration ─────────────────────────────────────────────
|