whalibmob 5.14.18 → 5.14.19
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/README.md +75 -2
- package/lib/Client.js +133 -55
- package/lib/SessionPaths.js +30 -0
- package/lib/signal/SignalProtocol.js +99 -10
- package/lib/signal/SignalStore.js +312 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2149,7 +2149,8 @@ History arrives in chunks over the first minute or so after linking, largest fir
|
|
|
2149
2149
|
| File | Holds |
|
|
2150
2150
|
|---|---|
|
|
2151
2151
|
| `<phone>.web.json` | link state — keys, `advSecretKey`, the device slot you were given |
|
|
2152
|
-
| `<phone>.web.signal.json` | Signal sessions
|
|
2152
|
+
| `<phone>.web.signal.json` | Signal sessions, signed pre-key, identities, LID map, pre-key counters |
|
|
2153
|
+
| `<phone>.web.pre-key-<id>.json` | one one-time pre-key — 812 of them |
|
|
2153
2154
|
| `<phone>.web.sk.json` | group SenderKeys |
|
|
2154
2155
|
| `<phone>.web.tctoken.json` | privacy tokens |
|
|
2155
2156
|
| `<phone>.web.appState.json` | app-state version and hash per collection |
|
|
@@ -2538,7 +2539,11 @@ whalibmob_auth/
|
|
|
2538
2539
|
├── android-apk-material-business.json
|
|
2539
2540
|
├── 919634847671/
|
|
2540
2541
|
│ ├── 919634847671.json ← the store: keys, device, version
|
|
2541
|
-
│ ├── 919634847671.signal.json ← Signal sessions
|
|
2542
|
+
│ ├── 919634847671.signal.json ← Signal sessions, signed pre-key, identities
|
|
2543
|
+
│ ├── 919634847671.pre-key-1.json ← one-time pre-keys, one file each
|
|
2544
|
+
│ ├── 919634847671.pre-key-2.json
|
|
2545
|
+
│ ├── … 812 of them
|
|
2546
|
+
│ ├── 919634847671.pre-key-812.json
|
|
2542
2547
|
│ ├── 919634847671.sk.json ← sender keys
|
|
2543
2548
|
│ ├── 919634847671.tctoken.json
|
|
2544
2549
|
│ ├── 919634847671.device-cache.json
|
|
@@ -2566,6 +2571,66 @@ of a pile by prefix.
|
|
|
2566
2571
|
> is moved unless you ask — `wa migrate-sessions` does that, one number or all
|
|
2567
2572
|
> of them, and re-running it is safe.
|
|
2568
2573
|
|
|
2574
|
+
### One-time Pre-keys
|
|
2575
|
+
|
|
2576
|
+
A pre-key is a one-shot Diffie-Hellman key the account leaves with the server so
|
|
2577
|
+
somebody who wants to message it can open a Signal session without it being
|
|
2578
|
+
online. Each one is handed out once and then gone. Run out and nobody can start
|
|
2579
|
+
a conversation with the number at all — the messages are not delayed, they are
|
|
2580
|
+
never sent, and nothing anywhere reports it.
|
|
2581
|
+
|
|
2582
|
+
whalibmob keeps a pool of **812**, generated the moment a session is created and
|
|
2583
|
+
written one key per file, in whatever authentication folder you gave it:
|
|
2584
|
+
|
|
2585
|
+
```
|
|
2586
|
+
~/.waSession/919634847671/
|
|
2587
|
+
├── 919634847671.pre-key-1.json
|
|
2588
|
+
├── 919634847671.pre-key-2.json
|
|
2589
|
+
└── … 919634847671.pre-key-812.json
|
|
2590
|
+
```
|
|
2591
|
+
|
|
2592
|
+
A companion link for the same number keeps its own pool beside it as
|
|
2593
|
+
`919634847671.web.pre-key-<id>.json`. The two are never shared: they are
|
|
2594
|
+
separate devices with separate identity keys, and a key offered under the wrong
|
|
2595
|
+
one cannot be answered.
|
|
2596
|
+
|
|
2597
|
+
The file is the same shape the reference client writes — the raw 32-byte public
|
|
2598
|
+
key and the private key, both BufferJSON:
|
|
2599
|
+
|
|
2600
|
+
```json
|
|
2601
|
+
{"private":{"type":"Buffer","data":"…"},"public":{"type":"Buffer","data":"…"}}
|
|
2602
|
+
```
|
|
2603
|
+
|
|
2604
|
+
**Keeping the server stocked.** Three things drive it, and all of them ask the
|
|
2605
|
+
server what it is actually holding rather than guessing from the local pool —
|
|
2606
|
+
the two drift apart, because the server spends a key on every bundle it hands
|
|
2607
|
+
out and none of that reaches this side.
|
|
2608
|
+
|
|
2609
|
+
| When | What it does |
|
|
2610
|
+
|---|---|
|
|
2611
|
+
| on login | asks the server for its count; **0** there means a full 812 goes up, anything low means a top-up of 5 |
|
|
2612
|
+
| when the server says it is running low | same check, same batch |
|
|
2613
|
+
| every 30 minutes | same check — for the session that stays up long enough to be drained without a word |
|
|
2614
|
+
|
|
2615
|
+
A batch is the next ids the server has not been sent, never the whole pool
|
|
2616
|
+
again, and only counts as sent once the server has acknowledged it — a rejected
|
|
2617
|
+
upload is retried four times with backoff and then left for the next check, with
|
|
2618
|
+
the same keys still queued. Ids are never reused, even after the key under one
|
|
2619
|
+
has been spent.
|
|
2620
|
+
|
|
2621
|
+
After the login upload the account also asks for the server's **key bundle
|
|
2622
|
+
digest** and compares the identity key and signed pre-key it is being served
|
|
2623
|
+
against the ones this session holds. A mismatch there is the failure that used
|
|
2624
|
+
to need the number registering again to clear.
|
|
2625
|
+
|
|
2626
|
+
None of this needs calling: `init()` and `connectWeb()` both do it, identically.
|
|
2627
|
+
|
|
2628
|
+
> [!NOTE]
|
|
2629
|
+
> **Sessions written before 5.14.19 keep every key they had.** Pre-keys used to
|
|
2630
|
+
> live inside `<phone>.signal.json`; on the first start they are moved out into
|
|
2631
|
+
> files of their own and the aggregate copy is dropped. The keys themselves do
|
|
2632
|
+
> not change, so the bundles the server has already handed out stay answerable.
|
|
2633
|
+
|
|
2569
2634
|
### Where the folder comes from
|
|
2570
2635
|
|
|
2571
2636
|
| order | source |
|
|
@@ -2720,6 +2785,14 @@ Creates a fresh credential store for the given phone number — the key pairs, r
|
|
|
2720
2785
|
|
|
2721
2786
|
It returns everything `createNewStore` does, plus a few fields kept for application code that expects them: `nextPreKeyId`, `firstUnuploadedPreKeyId`, `accountSyncCounter`, `accountSettings`, `processedHistoryMessages` and `advSecretKey`. Those extras live on the object only — `saveStore` does not write them, so they are not there again after a reload. Nothing in the library reads them; treat them as a convenience, not as state.
|
|
2722
2787
|
|
|
2788
|
+
> [!NOTE]
|
|
2789
|
+
> The two pre-key counters the library actually runs on are the ones in the
|
|
2790
|
+
> `SignalStore` — `nextPreKeyId()` and `firstUnuploadedPreKeyId()`, persisted in
|
|
2791
|
+
> `<phone>.signal.json`. They live there rather than in the store because a
|
|
2792
|
+
> number's mobile half and its companion half have a `SignalStore` each and must
|
|
2793
|
+
> never share a pre-key id space. The fields above are the same names on a
|
|
2794
|
+
> different object and do not drive anything.
|
|
2795
|
+
|
|
2723
2796
|
```js
|
|
2724
2797
|
const { initAuthCreds, saveStore } = require('whalibmob')
|
|
2725
2798
|
const path = require('path')
|
package/lib/Client.js
CHANGED
|
@@ -11,7 +11,7 @@ const { NoiseSocket } = require('./noise');
|
|
|
11
11
|
const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
|
|
12
12
|
const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
|
|
13
13
|
const { defaultBaseDir, sessionDirFor, isLegacyLayout,
|
|
14
|
-
SESSION_SUFFIXES } = require('./SessionPaths');
|
|
14
|
+
preKeyFilesFor, SESSION_SUFFIXES } = require('./SessionPaths');
|
|
15
15
|
const { getDeviceConfig } = require('./DeviceConfig');
|
|
16
16
|
const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
|
|
17
17
|
const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
|
|
@@ -47,7 +47,8 @@ const { NodeCache } = require('@cacheable/node-cache');
|
|
|
47
47
|
|
|
48
48
|
// Group metadata cache lifetime — matches DeviceManager's device cache.
|
|
49
49
|
const GROUP_META_CACHE_TTL = '5m';
|
|
50
|
-
const { SignalProtocol }
|
|
50
|
+
const { SignalProtocol, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT } =
|
|
51
|
+
require('./signal/SignalProtocol');
|
|
51
52
|
const { DeviceManager, jidStrToObj } = require('./DeviceManager');
|
|
52
53
|
const {
|
|
53
54
|
processHistorySyncNotification,
|
|
@@ -172,10 +173,10 @@ const PING_INTERVAL_MS = 25000;
|
|
|
172
173
|
const KEEPALIVE_INTERVAL = 20000;
|
|
173
174
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
|
|
174
175
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
const
|
|
176
|
+
// How many attempts an upload gets before it is left for the next check, and
|
|
177
|
+
// how long it waits between them.
|
|
178
|
+
const PRE_KEY_UPLOAD_TRIES = 4;
|
|
179
|
+
const PRE_KEY_UPLOAD_BACKOFF = (attempt) => Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
179
180
|
|
|
180
181
|
// How often to ask the server how many it has left. The drain is invisible from
|
|
181
182
|
// here — every bundle handed out is a key gone, and none of that reaches this
|
|
@@ -1229,6 +1230,22 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1229
1230
|
fs.renameSync(from, to);
|
|
1230
1231
|
moved.push(suffix);
|
|
1231
1232
|
}
|
|
1233
|
+
|
|
1234
|
+
// The pre-key files carry the number in their names too, one per key.
|
|
1235
|
+
// preKeyFilesFor anchors on the digits, so that is what the new name
|
|
1236
|
+
// replaces — the `.web` half and the id after it are kept as they are.
|
|
1237
|
+
const currentDigits = current.replace(/\D/g, '');
|
|
1238
|
+
for (const name of preKeyFilesFor(fromDir, current)) {
|
|
1239
|
+
const renamed = canonical + name.slice(currentDigits.length);
|
|
1240
|
+
const to = path.join(fromDir, renamed);
|
|
1241
|
+
if (fs.existsSync(to)) {
|
|
1242
|
+
throw new Error('Cannot rename session: ' + renamed +
|
|
1243
|
+
' already exists. Move it aside first.');
|
|
1244
|
+
}
|
|
1245
|
+
fs.renameSync(path.join(fromDir, name), to);
|
|
1246
|
+
moved.push(renamed);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1232
1249
|
if (!moved.length) throw new Error('No session files found for ' + current);
|
|
1233
1250
|
|
|
1234
1251
|
if (toDir !== fromDir) {
|
|
@@ -1306,7 +1323,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1306
1323
|
// bundle it is serving, which is the thing that goes quietly wrong and
|
|
1307
1324
|
// takes the account's reachability with it.
|
|
1308
1325
|
this._ensureServerPreKeys('login')
|
|
1309
|
-
.then(() => this._verifyServerKeyBundle('login'))
|
|
1326
|
+
.then(() => this._verifyServerKeyBundle('login'))
|
|
1327
|
+
// Both swallow their own failures; this is only here so that a rejection
|
|
1328
|
+
// from somewhere neither expected cannot take the process down with it.
|
|
1329
|
+
.catch(err => _whaDbg('[DBG] PREKEY login check failed: ' + (err && err.message)));
|
|
1310
1330
|
|
|
1311
1331
|
// Same telemetry ping the web client sends on every completed login.
|
|
1312
1332
|
this._sendUnifiedSession();
|
|
@@ -3041,7 +3061,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3041
3061
|
// rotating index wraps back onto ids that are still in flight.
|
|
3042
3062
|
let pkId = existing ? existing.pkId : null;
|
|
3043
3063
|
if (pkId === null && this._signal) {
|
|
3044
|
-
const allKeys = this._signal.getPreKeysForUpload(
|
|
3064
|
+
const allKeys = this._signal.getPreKeysForUpload(INITIAL_PREKEY_COUNT);
|
|
3045
3065
|
const free = allKeys.find(k => !this._retryAdvertisedPreKeys.has(k.keyId));
|
|
3046
3066
|
// If every key is already spoken for, fall back to rotating rather than
|
|
3047
3067
|
// sending no bundle at all — a stale id beats no retry.
|
|
@@ -3069,7 +3089,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3069
3089
|
try {
|
|
3070
3090
|
const spk = this._signal.getSignedPreKeyForUpload();
|
|
3071
3091
|
const identKey = this._signal.getIdentityKey();
|
|
3072
|
-
const allPreKeys = this._signal.getPreKeysForUpload(
|
|
3092
|
+
const allPreKeys = this._signal.getPreKeysForUpload(INITIAL_PREKEY_COUNT);
|
|
3073
3093
|
const pk = allPreKeys.find(k => k.keyId === pkId) || allPreKeys[0];
|
|
3074
3094
|
|
|
3075
3095
|
if (spk && identKey && pk) {
|
|
@@ -3527,39 +3547,81 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3527
3547
|
|
|
3528
3548
|
// ─── Pre-key upload ───────────────────────────────────────────────────────
|
|
3529
3549
|
|
|
3530
|
-
|
|
3531
|
-
|
|
3550
|
+
// Send the server a batch of one-time keys, plus the identity and signed
|
|
3551
|
+
// pre-key that go with them.
|
|
3552
|
+
//
|
|
3553
|
+
// The batch is the next `count` ids the server has not been sent — not the
|
|
3554
|
+
// whole local pool, which is what this used to send. Eight hundred keys went
|
|
3555
|
+
// up on every login and every top-up, the same eight hundred each time, and
|
|
3556
|
+
// the ids the server had already been given were sent again while the ones
|
|
3557
|
+
// generated since were not.
|
|
3558
|
+
//
|
|
3559
|
+
// Awaited, and retried, because it is the one message whose loss is silent:
|
|
3560
|
+
// an upload that never lands leaves the server with nothing to hand out and
|
|
3561
|
+
// the account unreachable, with no error anywhere to say so.
|
|
3562
|
+
//
|
|
3563
|
+
// @returns {Promise<boolean>} whether the server acknowledged it
|
|
3564
|
+
async _uploadPreKeys(count) {
|
|
3565
|
+
if (!this._signal || !this._socket || !this._connected) return false;
|
|
3566
|
+
count = count > 0 ? count : MIN_PREKEY_COUNT;
|
|
3532
3567
|
|
|
3533
|
-
const preKeys
|
|
3568
|
+
const { preKeys, update } = this._signal.getNextPreKeys(count);
|
|
3534
3569
|
const spk = this._signal.getSignedPreKeyForUpload();
|
|
3535
3570
|
const identKey = this._signal.getIdentityKey();
|
|
3536
|
-
if (!preKeys.length || !spk) return;
|
|
3537
|
-
|
|
3538
|
-
const preKeyNodes = preKeys.map(pk => new BinaryNode('key', {}, [
|
|
3539
|
-
new BinaryNode('id', {}, intToBytes(pk.keyId, 3)),
|
|
3540
|
-
new BinaryNode('value', {}, stripKeyPrefix(pk.pubKey))
|
|
3541
|
-
]));
|
|
3571
|
+
if (!preKeys.length || !spk) return false;
|
|
3542
3572
|
|
|
3543
|
-
const
|
|
3544
|
-
new BinaryNode('id', {}, intToBytes(spk.keyId, 3)),
|
|
3545
|
-
new BinaryNode('value', {}, stripKeyPrefix(spk.keyPair.pubKey)),
|
|
3546
|
-
new BinaryNode('signature', {}, spk.signature)
|
|
3547
|
-
]);
|
|
3548
|
-
|
|
3549
|
-
const pkId = this._genMsgId();
|
|
3550
|
-
_whaDbg('[DBG] SEND uploadPreKeys IQ id=' + pkId);
|
|
3551
|
-
this._socket.sendNode(new BinaryNode('iq', {
|
|
3552
|
-
id: pkId,
|
|
3553
|
-
to: 's.whatsapp.net',
|
|
3554
|
-
type: 'set',
|
|
3555
|
-
xmlns: 'encrypt'
|
|
3556
|
-
}, [
|
|
3573
|
+
const children = [
|
|
3557
3574
|
new BinaryNode('registration', {}, intToBytes(this._store.registrationId, 4)),
|
|
3558
3575
|
new BinaryNode('type', {}, Buffer.from([5])),
|
|
3559
3576
|
new BinaryNode('identity', {}, stripKeyPrefix(identKey)),
|
|
3560
|
-
new BinaryNode('list', {},
|
|
3561
|
-
|
|
3562
|
-
|
|
3577
|
+
new BinaryNode('list', {}, preKeys.map(pk => new BinaryNode('key', {}, [
|
|
3578
|
+
new BinaryNode('id', {}, intToBytes(pk.keyId, 3)),
|
|
3579
|
+
new BinaryNode('value', {}, stripKeyPrefix(pk.pubKey))
|
|
3580
|
+
]))),
|
|
3581
|
+
new BinaryNode('skey', {}, [
|
|
3582
|
+
new BinaryNode('id', {}, intToBytes(spk.keyId, 3)),
|
|
3583
|
+
new BinaryNode('value', {}, stripKeyPrefix(spk.keyPair.pubKey)),
|
|
3584
|
+
new BinaryNode('signature', {}, spk.signature)
|
|
3585
|
+
])
|
|
3586
|
+
];
|
|
3587
|
+
|
|
3588
|
+
for (let attempt = 0; attempt < PRE_KEY_UPLOAD_TRIES; attempt++) {
|
|
3589
|
+
if (!this._socket || !this._connected) break;
|
|
3590
|
+
|
|
3591
|
+
const pkId = this._genMsgId();
|
|
3592
|
+
_whaDbg('[DBG] SEND uploadPreKeys IQ id=' + pkId + ' count=' + preKeys.length +
|
|
3593
|
+
' ids=' + preKeys[0].keyId + '..' + preKeys[preKeys.length - 1].keyId +
|
|
3594
|
+
(attempt ? ' (retry ' + attempt + ')' : ''));
|
|
3595
|
+
|
|
3596
|
+
let resp = null;
|
|
3597
|
+
try {
|
|
3598
|
+
resp = await this._sendIq(new BinaryNode('iq', {
|
|
3599
|
+
id: pkId,
|
|
3600
|
+
to: 's.whatsapp.net',
|
|
3601
|
+
type: 'set',
|
|
3602
|
+
xmlns: 'encrypt'
|
|
3603
|
+
}, children));
|
|
3604
|
+
} catch (err) {
|
|
3605
|
+
_whaDbg('[DBG] PREKEY upload send failed: ' + (err && err.message));
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
if (resp && !(resp.attrs && resp.attrs.type === 'error')) {
|
|
3609
|
+
// Only now. The keys themselves were spent the moment they were
|
|
3610
|
+
// generated — an id is never handed out twice — but a batch the server
|
|
3611
|
+
// did not take is a batch that still has to go, so the range stays
|
|
3612
|
+
// unuploaded until it is acknowledged.
|
|
3613
|
+
this._signal.commitPreKeyUpload(update);
|
|
3614
|
+
_whaDbg('[DBG] PREKEY uploaded ' + preKeys.length + ' key(s)');
|
|
3615
|
+
return true;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
if (attempt < PRE_KEY_UPLOAD_TRIES - 1) {
|
|
3619
|
+
await new Promise(r => setTimeout(r, PRE_KEY_UPLOAD_BACKOFF(attempt)));
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
_whaDbg('[DBG] PREKEY upload gave up after ' + PRE_KEY_UPLOAD_TRIES + ' attempt(s)');
|
|
3624
|
+
return false;
|
|
3563
3625
|
}
|
|
3564
3626
|
|
|
3565
3627
|
// How many one-time keys the server is still holding for us.
|
|
@@ -3671,8 +3733,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3671
3733
|
|
|
3672
3734
|
if (digest.missing) {
|
|
3673
3735
|
_whaDbg('[DBG] KEYBUNDLE server returned no digest (' + reason + ') — re-uploading');
|
|
3674
|
-
this.
|
|
3675
|
-
this._uploadPreKeys();
|
|
3736
|
+
await this._uploadPreKeys(INITIAL_PREKEY_COUNT);
|
|
3676
3737
|
return;
|
|
3677
3738
|
}
|
|
3678
3739
|
|
|
@@ -3705,8 +3766,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3705
3766
|
|
|
3706
3767
|
_whaDbg('[DBG] KEYBUNDLE server disagrees on ' + mismatches.join(' and ') +
|
|
3707
3768
|
' (' + reason + ') — re-uploading');
|
|
3708
|
-
|
|
3709
|
-
|
|
3769
|
+
// What is wrong here is the identity or the signed pre-key, and both ride
|
|
3770
|
+
// along with any batch, however small. The one-time pool was settled a
|
|
3771
|
+
// moment ago by _ensureServerPreKeys.
|
|
3772
|
+
await this._uploadPreKeys(MIN_PREKEY_COUNT);
|
|
3710
3773
|
} catch (err) {
|
|
3711
3774
|
_whaDbg('[DBG] KEYBUNDLE check failed (' + reason + '): ' + (err && err.message));
|
|
3712
3775
|
}
|
|
@@ -3726,33 +3789,48 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3726
3789
|
if (!this._signal) return;
|
|
3727
3790
|
try {
|
|
3728
3791
|
const serverCount = await this._queryServerPreKeyCount();
|
|
3792
|
+
|
|
3793
|
+
// How big a batch this warrants. A server holding none of ours has either
|
|
3794
|
+
// never had them or has handed every one out, and either way the account
|
|
3795
|
+
// is unreachable until a full pool is back up there; anything else is a
|
|
3796
|
+
// top-up. A session that has never uploaded counts as the first case even
|
|
3797
|
+
// when the server says it has some, because whatever it is holding did
|
|
3798
|
+
// not come from here.
|
|
3799
|
+
const neverUploaded = this._signal.firstUnuploadedPreKeyId() <= 1;
|
|
3800
|
+
const count = (serverCount === 0 || neverUploaded)
|
|
3801
|
+
? INITIAL_PREKEY_COUNT : MIN_PREKEY_COUNT;
|
|
3802
|
+
|
|
3729
3803
|
if (serverCount === null) {
|
|
3730
|
-
// No usable answer.
|
|
3731
|
-
//
|
|
3804
|
+
// No usable answer. Upload anyway — one we may not have needed costs
|
|
3805
|
+
// nothing next to an account nobody can reach.
|
|
3732
3806
|
_whaDbg('[DBG] PREKEY server count unavailable (' + reason + ') — uploading anyway');
|
|
3733
|
-
this.
|
|
3734
|
-
this._uploadPreKeys();
|
|
3807
|
+
await this._uploadPreKeys(count);
|
|
3735
3808
|
return;
|
|
3736
3809
|
}
|
|
3737
3810
|
|
|
3738
|
-
|
|
3739
|
-
|
|
3811
|
+
// The newest key we generated. If it is not in the pool the store has
|
|
3812
|
+
// lost keys the server may still be serving, and the bundle needs
|
|
3813
|
+
// rebuilding whatever the count says.
|
|
3814
|
+
const currentId = this._signal.currentPreKeyId();
|
|
3815
|
+
const missingCurrent = currentId > 0 && !this._signal.hasPreKey(currentId);
|
|
3816
|
+
const lowServer = serverCount <= count;
|
|
3817
|
+
|
|
3818
|
+
_whaDbg('[DBG] PREKEY server holds ' + serverCount + ' (' + reason +
|
|
3819
|
+
'), current id ' + currentId + (missingCurrent ? ' MISSING' : ' present'));
|
|
3740
3820
|
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3821
|
+
if (!lowServer && !missingCurrent) return;
|
|
3822
|
+
|
|
3823
|
+
const why = [];
|
|
3824
|
+
if (lowServer) why.push('server count low (' + serverCount + ')');
|
|
3825
|
+
if (missingCurrent) why.push('current pre-key ' + currentId + ' missing from storage');
|
|
3826
|
+
_whaDbg('[DBG] PREKEY uploading ' + count + ' due to: ' + why.join(', '));
|
|
3827
|
+
|
|
3828
|
+
await this._uploadPreKeys(count);
|
|
3745
3829
|
} catch (err) {
|
|
3746
3830
|
_whaDbg('[DBG] PREKEY check failed (' + reason + '): ' + (err && err.message));
|
|
3747
3831
|
}
|
|
3748
3832
|
}
|
|
3749
3833
|
|
|
3750
|
-
// Make sure there is something to upload before uploading.
|
|
3751
|
-
_replenishLocalPreKeys() {
|
|
3752
|
-
if (!this._signal) return;
|
|
3753
|
-
if (this._signal.preKeyCount() < 20) this._signal.replenishPreKeys();
|
|
3754
|
-
}
|
|
3755
|
-
|
|
3756
3834
|
_checkAndReplenishPreKeys() {
|
|
3757
3835
|
if (!this._signal) return;
|
|
3758
3836
|
// The server said it is running low, so ask it how low and act on that
|
package/lib/SessionPaths.js
CHANGED
|
@@ -67,6 +67,28 @@ const SESSION_SUFFIXES = [
|
|
|
67
67
|
'.web.appStateKeys.json'
|
|
68
68
|
];
|
|
69
69
|
|
|
70
|
+
// The one-time pre-keys are the exception to the list above. There are 812 of
|
|
71
|
+
// them and they are a file each — <phone>.pre-key-<id>.json for the number
|
|
72
|
+
// itself, <phone>.web.pre-key-<id>.json for its companion half — so they cannot
|
|
73
|
+
// be enumerated by suffix and have to be found by name instead.
|
|
74
|
+
const PRE_KEY_FILE_RE = (phone) =>
|
|
75
|
+
new RegExp('^' + String(phone).replace(/\D/g, '') + '(\\.web)?\\.pre-key-\\d+\\.json$');
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every pre-key file a number owns in a directory, by name.
|
|
79
|
+
*
|
|
80
|
+
* Anything that moves a session has to move these too: a number whose pre-keys
|
|
81
|
+
* are left behind still has its Signal sessions and looks perfectly healthy
|
|
82
|
+
* until somebody tries to open a new one with it, and then the pkmsg arrives
|
|
83
|
+
* naming a key that is not there any more.
|
|
84
|
+
*/
|
|
85
|
+
function preKeyFilesFor(dir, phone) {
|
|
86
|
+
const re = PRE_KEY_FILE_RE(phone);
|
|
87
|
+
let entries;
|
|
88
|
+
try { entries = fs.readdirSync(dir); } catch (_) { return []; }
|
|
89
|
+
return entries.filter(name => re.test(name));
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
// Files that belong to the installation rather than to any number, and stay at
|
|
71
93
|
// the top of the base directory.
|
|
72
94
|
const SHARED_FILES = [
|
|
@@ -202,6 +224,13 @@ function migrateSession(baseDir, phone) {
|
|
|
202
224
|
moved.push(phone + suffix);
|
|
203
225
|
}
|
|
204
226
|
|
|
227
|
+
for (const name of preKeyFilesFor(baseDir, phone)) {
|
|
228
|
+
const dest = path.join(to, name);
|
|
229
|
+
if (_exists(dest)) { skipped.push(name); continue; }
|
|
230
|
+
fs.renameSync(path.join(baseDir, name), dest);
|
|
231
|
+
moved.push(name);
|
|
232
|
+
}
|
|
233
|
+
|
|
205
234
|
return { phone, from: baseDir, to, moved, skipped };
|
|
206
235
|
}
|
|
207
236
|
|
|
@@ -213,6 +242,7 @@ module.exports = {
|
|
|
213
242
|
webStoreFileFor,
|
|
214
243
|
listSessions,
|
|
215
244
|
migrateSession,
|
|
245
|
+
preKeyFilesFor,
|
|
216
246
|
SESSION_SUFFIXES,
|
|
217
247
|
SHARED_FILES
|
|
218
248
|
};
|
|
@@ -8,9 +8,22 @@ const { GroupCipher, GroupSessionBuilder, SenderKeyRecord, SenderKeyName, Sender
|
|
|
8
8
|
|
|
9
9
|
const { SessionCipher, SessionBuilder, ProtocolAddress, keyhelper } = libsignal;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// The pre-key pool, sized the way the reference client sizes it.
|
|
12
|
+
//
|
|
13
|
+
// INITIAL_PREKEY_COUNT what a fresh account puts on the server, and what it
|
|
14
|
+
// puts back when the server turns out to be holding
|
|
15
|
+
// none of ours
|
|
16
|
+
// MIN_PREKEY_COUNT the routine top-up, sent when the server says it is
|
|
17
|
+
// running low
|
|
18
|
+
//
|
|
19
|
+
// The pool is generated up front rather than on demand, so the authentication
|
|
20
|
+
// folder holds all 812 keys from the moment the session exists.
|
|
21
|
+
const INITIAL_PREKEY_COUNT = 812;
|
|
22
|
+
const MIN_PREKEY_COUNT = 5;
|
|
23
|
+
|
|
24
|
+
// Below this many keys left, a session that is starting up refills to the full
|
|
25
|
+
// pool rather than waiting for the server to ask.
|
|
26
|
+
const PRE_KEY_LOW_WATER = 50;
|
|
14
27
|
|
|
15
28
|
// ─── Per-JID async mutex ────────────
|
|
16
29
|
// Prevents race conditions when two messages are encrypted simultaneously
|
|
@@ -128,17 +141,22 @@ class SignalProtocol {
|
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
_ensurePreKeys() {
|
|
131
|
-
if (this.store.preKeyCount() >=
|
|
144
|
+
if (this.store.preKeyCount() >= PRE_KEY_LOW_WATER) return;
|
|
132
145
|
this.replenishPreKeys();
|
|
133
146
|
}
|
|
134
147
|
|
|
148
|
+
/** Top the local pool back up to INITIAL_PREKEY_COUNT. */
|
|
135
149
|
replenishPreKeys() {
|
|
136
|
-
const
|
|
137
|
-
const need = PRE_KEY_COUNT - have;
|
|
150
|
+
const need = INITIAL_PREKEY_COUNT - this.store.preKeyCount();
|
|
138
151
|
if (need <= 0) return [];
|
|
152
|
+
return this.generatePreKeys(need);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Generate `count` fresh pre-keys, continuing the id sequence. */
|
|
156
|
+
generatePreKeys(count) {
|
|
139
157
|
const startId = this.store.nextPreKeyId();
|
|
140
158
|
const newKeys = [];
|
|
141
|
-
for (let i = 0; i <
|
|
159
|
+
for (let i = 0; i < count; i++) {
|
|
142
160
|
const kp = keyhelper.generatePreKey(startId + i);
|
|
143
161
|
this.store.storePreKey(kp.keyId, kp.keyPair);
|
|
144
162
|
newKeys.push({ keyId: kp.keyId, pubKey: kp.keyPair.pubKey });
|
|
@@ -150,10 +168,78 @@ class SignalProtocol {
|
|
|
150
168
|
return this.store.preKeyCount();
|
|
151
169
|
}
|
|
152
170
|
|
|
171
|
+
/** The id of the newest pre-key generated, 0 when none ever was. */
|
|
172
|
+
currentPreKeyId() {
|
|
173
|
+
return this.store.nextPreKeyId() - 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
hasPreKey(keyId) {
|
|
177
|
+
return this.store.hasPreKey(keyId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
firstUnuploadedPreKeyId() {
|
|
181
|
+
return this.store.firstUnuploadedPreKeyId();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The next batch to send the server, generating whatever is missing to make
|
|
186
|
+
* `count` of them — the same arithmetic the reference client does.
|
|
187
|
+
*
|
|
188
|
+
* `firstUnuploadedPreKeyId` is the lowest id the server has not been sent, so
|
|
189
|
+
* the batch is the `count` ids from there up. Whatever of those has already
|
|
190
|
+
* been generated is reused; the rest is made now, continuing from
|
|
191
|
+
* `nextPreKeyId`. That is what stops an account from re-sending its whole
|
|
192
|
+
* pool on every top-up — which is what this used to do, eight hundred keys at
|
|
193
|
+
* a time, several times a session.
|
|
194
|
+
*
|
|
195
|
+
* The returned `update` is not applied here. Ids are spent the moment they
|
|
196
|
+
* are generated (storePreKey advances nextPreKeyId), but a batch only counts
|
|
197
|
+
* as uploaded once the server has acknowledged it — see commitPreKeyUpload().
|
|
198
|
+
*
|
|
199
|
+
* @returns {{preKeys: Array<{keyId, pubKey}>, update: {firstUnuploadedPreKeyId: number}}}
|
|
200
|
+
*/
|
|
201
|
+
getNextPreKeys(count) {
|
|
202
|
+
count = count > 0 ? Math.floor(count) : MIN_PREKEY_COUNT;
|
|
203
|
+
|
|
204
|
+
const first = this.store.firstUnuploadedPreKeyId();
|
|
205
|
+
const available = this.store.nextPreKeyId() - first;
|
|
206
|
+
const remaining = count - available;
|
|
207
|
+
if (remaining > 0) this.generatePreKeys(remaining);
|
|
208
|
+
|
|
209
|
+
const last = first + count - 1;
|
|
210
|
+
const preKeys = [];
|
|
211
|
+
for (let id = first; id <= last; id++) {
|
|
212
|
+
const raw = this.store._preKeys[String(id)];
|
|
213
|
+
// An id in the range whose key has already been consumed. The reference
|
|
214
|
+
// client would put an undefined key on the wire here; skip it instead.
|
|
215
|
+
if (!raw) continue;
|
|
216
|
+
preKeys.push({ keyId: id, pubKey: Buffer.from(raw.pubKey, 'base64') });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { preKeys, update: { firstUnuploadedPreKeyId: last + 1 } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Record that the batch getNextPreKeys() returned reached the server. */
|
|
223
|
+
commitPreKeyUpload(update) {
|
|
224
|
+
if (!update) return;
|
|
225
|
+
this.store.setFirstUnuploadedPreKeyId(update.firstUnuploadedPreKeyId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Every pre-key still in the pool, oldest id first.
|
|
230
|
+
*
|
|
231
|
+
* Used where a single key has to be picked out by id — the bundle attached to
|
|
232
|
+
* a retry receipt — rather than for uploads, which go through
|
|
233
|
+
* getNextPreKeys().
|
|
234
|
+
*/
|
|
153
235
|
getPreKeysForUpload(max) {
|
|
154
|
-
max = max ||
|
|
236
|
+
max = max || INITIAL_PREKEY_COUNT;
|
|
237
|
+
const ids = Object.keys(this.store._preKeys)
|
|
238
|
+
.map(Number)
|
|
239
|
+
.filter(n => Number.isFinite(n))
|
|
240
|
+
.sort((a, b) => a - b);
|
|
241
|
+
|
|
155
242
|
const preKeys = [];
|
|
156
|
-
const ids = Object.keys(this.store._preKeys).map(Number);
|
|
157
243
|
for (const id of ids.slice(0, max)) {
|
|
158
244
|
const raw = this.store._preKeys[String(id)];
|
|
159
245
|
preKeys.push({
|
|
@@ -362,4 +448,7 @@ function jidToAddress(jid) {
|
|
|
362
448
|
return new ProtocolAddress(user, deviceId);
|
|
363
449
|
}
|
|
364
450
|
|
|
365
|
-
module.exports = {
|
|
451
|
+
module.exports = {
|
|
452
|
+
SignalProtocol, jidToAddress, pad, unpad,
|
|
453
|
+
INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT
|
|
454
|
+
};
|
|
@@ -38,6 +38,91 @@ const { SessionRecord } = require('./libsignal');
|
|
|
38
38
|
|
|
39
39
|
const DEBOUNCE_MS = 300;
|
|
40
40
|
|
|
41
|
+
// ─── Pre-key files ────────────────────────────────────────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// One-time pre-keys do not live in the snapshot above. They get a file each,
|
|
44
|
+
// named the way Baileys names them:
|
|
45
|
+
//
|
|
46
|
+
// <base>/40756469325/40756469325.pre-key-1.json
|
|
47
|
+
// ...
|
|
48
|
+
// <base>/40756469325/40756469325.pre-key-812.json
|
|
49
|
+
//
|
|
50
|
+
// Everything else about a pre-key is small, but there are 812 of them
|
|
51
|
+
// and every one that is consumed rewrites the whole store — so keeping them in
|
|
52
|
+
// the aggregate file meant a ~95 KB write on each incoming pkmsg, and made the
|
|
53
|
+
// pool something you could only inspect by parsing the file. A file each is
|
|
54
|
+
// what the reference client does, it is what anyone comparing the two
|
|
55
|
+
// authentication folders expects to see, and a write now touches one key.
|
|
56
|
+
//
|
|
57
|
+
// The stem in front of `.pre-key-` is the one the .signal.json file already
|
|
58
|
+
// carries, so a number's mobile half (40756469325.pre-key-N.json) and its
|
|
59
|
+
// companion half (40756469325.web.pre-key-N.json) keep separate pools, as they
|
|
60
|
+
// must — and a session still sitting in the old flat layout, where every
|
|
61
|
+
// number shares one directory, does not collide with its neighbours either.
|
|
62
|
+
//
|
|
63
|
+
// A pre-key on disk is byte-for-byte what Baileys writes: BufferJSON, the raw
|
|
64
|
+
// 32-byte public key with no type prefix.
|
|
65
|
+
//
|
|
66
|
+
// {"private":{"type":"Buffer","data":"…"},"public":{"type":"Buffer","data":"…"}}
|
|
67
|
+
//
|
|
68
|
+
// In memory whalibmob carries the 33-byte 0x05-prefixed public key libsignal
|
|
69
|
+
// hands out, so the prefix comes off on the way to disk and goes back on on the
|
|
70
|
+
// way in.
|
|
71
|
+
//
|
|
72
|
+
// Unlike the snapshot these are written straight, without the temp-file dance.
|
|
73
|
+
// The blast radius of a torn write is one key: it fails to parse at load, is
|
|
74
|
+
// skipped, and the pool is topped back up. The snapshot cannot be treated that
|
|
75
|
+
// way because losing it loses every session the account has.
|
|
76
|
+
|
|
77
|
+
const PRE_KEY_INFIX = '.pre-key-';
|
|
78
|
+
const SIGNAL_KEY_PREFIX = 0x05;
|
|
79
|
+
|
|
80
|
+
// How many pre-key files may be in flight at once. Eight hundred parallel
|
|
81
|
+
// writes is a reliable way to meet EMFILE.
|
|
82
|
+
const PRE_KEY_WRITE_CONCURRENCY = 32;
|
|
83
|
+
|
|
84
|
+
function _escapeRe(s) {
|
|
85
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A BufferJSON field, in any of the shapes it is written in.
|
|
89
|
+
function _bufFrom(field) {
|
|
90
|
+
if (!field) return null;
|
|
91
|
+
if (Buffer.isBuffer(field)) return field;
|
|
92
|
+
if (typeof field === 'string') return Buffer.from(field, 'base64');
|
|
93
|
+
if (field.type === 'Buffer') {
|
|
94
|
+
return Array.isArray(field.data)
|
|
95
|
+
? Buffer.from(field.data)
|
|
96
|
+
: Buffer.from(String(field.data), 'base64');
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _encodePreKeyFile(entry) {
|
|
102
|
+
const pub = Buffer.from(entry.pubKey, 'base64');
|
|
103
|
+
const raw = (pub.length === 33 && pub[0] === SIGNAL_KEY_PREFIX) ? pub.slice(1) : pub;
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
private: { type: 'Buffer', data: entry.privKey },
|
|
106
|
+
public: { type: 'Buffer', data: raw.toString('base64') }
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _decodePreKeyFile(text) {
|
|
111
|
+
const obj = JSON.parse(text);
|
|
112
|
+
const priv = _bufFrom(obj && obj.private);
|
|
113
|
+
let pub = _bufFrom(obj && obj.public);
|
|
114
|
+
if (!priv || !pub || priv.length !== 32) return null;
|
|
115
|
+
if (pub.length === 32) pub = Buffer.concat([Buffer.from([SIGNAL_KEY_PREFIX]), pub]);
|
|
116
|
+
if (pub.length !== 33) return null;
|
|
117
|
+
return { pubKey: pub.toString('base64'), privKey: priv.toString('base64') };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A stored counter, or 1 when the file predates it or holds nonsense.
|
|
121
|
+
function _counter(value) {
|
|
122
|
+
const n = Number(value);
|
|
123
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
41
126
|
class SignalStore {
|
|
42
127
|
constructor() {
|
|
43
128
|
this._identityKeyPair = null;
|
|
@@ -68,6 +153,30 @@ class SignalStore {
|
|
|
68
153
|
this._writing = false;
|
|
69
154
|
this._writeAgain = false;
|
|
70
155
|
|
|
156
|
+
// Where the pre-key files go, and which ids are waiting to be written or
|
|
157
|
+
// unlinked. Empty until attachFile() — a store with nowhere to write keeps
|
|
158
|
+
// its pre-keys in memory and queues nothing.
|
|
159
|
+
this._pkDir = null;
|
|
160
|
+
this._pkPrefix = null;
|
|
161
|
+
this._pkPending = new Set();
|
|
162
|
+
this._pkTimer = null;
|
|
163
|
+
this._pkWriting = false;
|
|
164
|
+
|
|
165
|
+
// The two counters Baileys keeps in creds. They live here instead because a
|
|
166
|
+
// number's mobile and companion halves have a .signal.json each and must
|
|
167
|
+
// never share a pre-key id space.
|
|
168
|
+
//
|
|
169
|
+
// _nextPreKeyId the id the next generated key takes
|
|
170
|
+
// _firstUnuploadedPreKeyId the lowest id the server has not been sent
|
|
171
|
+
//
|
|
172
|
+
// Both only ever go up. An id that has been handed to the server is spent
|
|
173
|
+
// even after the key itself is consumed and deleted, so deriving the next
|
|
174
|
+
// id from what is still on disk — which is what this used to do — walks
|
|
175
|
+
// backwards over ids the server is still serving and hands two different
|
|
176
|
+
// keys out under one id.
|
|
177
|
+
this._nextPreKeyId = 1;
|
|
178
|
+
this._firstUnuploadedPreKeyId = 1;
|
|
179
|
+
|
|
71
180
|
// Flush synchronously on exit so no state is ever lost.
|
|
72
181
|
//
|
|
73
182
|
// Kept as references so they can be taken off again: a store that has been
|
|
@@ -98,6 +207,12 @@ class SignalStore {
|
|
|
98
207
|
const stale = this._filePath ? [this._tmpPath(true), this._tmpPath(false)] : [];
|
|
99
208
|
this._filePath = null;
|
|
100
209
|
this._writeAgain = false;
|
|
210
|
+
// Same for the pre-key files: _flushSync() above has written whatever was
|
|
211
|
+
// outstanding, so let the directory go before the caller moves it.
|
|
212
|
+
if (this._pkTimer) { clearTimeout(this._pkTimer); this._pkTimer = null; }
|
|
213
|
+
this._pkDir = null;
|
|
214
|
+
this._pkPrefix = null;
|
|
215
|
+
this._pkPending.clear();
|
|
101
216
|
for (const f of stale) { try { fs.unlinkSync(f); } catch (_) {} }
|
|
102
217
|
}
|
|
103
218
|
|
|
@@ -108,19 +223,90 @@ class SignalStore {
|
|
|
108
223
|
|
|
109
224
|
attachFile(filePath) {
|
|
110
225
|
this._filePath = filePath;
|
|
226
|
+
// The pre-key files sit beside the snapshot and share its stem, so
|
|
227
|
+
// 40756469325.signal.json gives 40756469325.pre-key-<id>.json and
|
|
228
|
+
// 40756469325.web.signal.json gives 40756469325.web.pre-key-<id>.json.
|
|
229
|
+
const base = path.basename(filePath);
|
|
230
|
+
this._pkDir = path.dirname(filePath);
|
|
231
|
+
this._pkPrefix = base.endsWith('.signal.json')
|
|
232
|
+
? base.slice(0, -'.signal.json'.length)
|
|
233
|
+
: base.replace(/\.json$/, '');
|
|
111
234
|
this._load();
|
|
112
235
|
}
|
|
113
236
|
|
|
237
|
+
/** Where a pre-key of this id belongs on disk. */
|
|
238
|
+
_preKeyFile(id) {
|
|
239
|
+
return path.join(this._pkDir, this._pkPrefix + PRE_KEY_INFIX + id + '.json');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Read every pre-key file this session owns. Returns how many were read.
|
|
243
|
+
//
|
|
244
|
+
// A file that will not parse is skipped rather than fatal: it costs one key
|
|
245
|
+
// out of 812, and the pool is topped back up on the next check.
|
|
246
|
+
_loadPreKeyFiles() {
|
|
247
|
+
if (!this._pkDir) return 0;
|
|
248
|
+
let entries;
|
|
249
|
+
try { entries = fs.readdirSync(this._pkDir); } catch (_) { return 0; }
|
|
250
|
+
|
|
251
|
+
const re = new RegExp('^' + _escapeRe(this._pkPrefix + PRE_KEY_INFIX) + '(\\d+)\\.json$');
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (const name of entries) {
|
|
254
|
+
const m = re.exec(name);
|
|
255
|
+
if (!m) continue;
|
|
256
|
+
try {
|
|
257
|
+
const entry = _decodePreKeyFile(
|
|
258
|
+
fs.readFileSync(path.join(this._pkDir, name), 'utf8'));
|
|
259
|
+
if (entry) { this._preKeys[m[1]] = entry; count++; }
|
|
260
|
+
} catch (_) {}
|
|
261
|
+
}
|
|
262
|
+
return count;
|
|
263
|
+
}
|
|
264
|
+
|
|
114
265
|
_load() {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
266
|
+
this._preKeys = {};
|
|
267
|
+
|
|
268
|
+
let raw = null;
|
|
269
|
+
if (this._filePath && fs.existsSync(this._filePath)) {
|
|
270
|
+
try { raw = JSON.parse(fs.readFileSync(this._filePath, 'utf8')); } catch (_) {}
|
|
271
|
+
}
|
|
272
|
+
if (raw) {
|
|
118
273
|
this._sessions = raw.sessions || {};
|
|
119
|
-
this._preKeys = raw.preKeys || {};
|
|
120
274
|
this._signedPreKeys = raw.signedPreKeys || {};
|
|
121
275
|
this._identities = raw.identities || {};
|
|
122
276
|
this._lidMappings = raw.lidMappings || {}; // phone → lid, persisted
|
|
123
|
-
|
|
277
|
+
this._nextPreKeyId = _counter(raw.nextPreKeyId);
|
|
278
|
+
this._firstUnuploadedPreKeyId = _counter(raw.firstUnuploadedPreKeyId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Files first — they are where a pre-key lives now.
|
|
282
|
+
this._loadPreKeyFiles();
|
|
283
|
+
|
|
284
|
+
// Anything still only in the old aggregate block moves out to a file of its
|
|
285
|
+
// own. A session written before this change has its whole pool in there and
|
|
286
|
+
// the server is serving those exact keys, so dropping them would leave
|
|
287
|
+
// every bundle already handed out unanswerable.
|
|
288
|
+
let migrated = 0;
|
|
289
|
+
const legacy = (raw && raw.preKeys) || {};
|
|
290
|
+
for (const id of Object.keys(legacy)) {
|
|
291
|
+
if (this._preKeys[id]) continue;
|
|
292
|
+
const entry = legacy[id];
|
|
293
|
+
if (!entry || !entry.pubKey || !entry.privKey) continue;
|
|
294
|
+
this._preKeys[id] = entry;
|
|
295
|
+
this._pkPending.add(id);
|
|
296
|
+
migrated++;
|
|
297
|
+
}
|
|
298
|
+
if (migrated) {
|
|
299
|
+
this._flushPreKeysSync();
|
|
300
|
+
this._save(); // rewrites the snapshot without the old block
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// An id on disk is an id that has been used, whatever the counter says.
|
|
304
|
+
const ids = Object.keys(this._preKeys).map(Number).filter(n => Number.isFinite(n) && n > 0);
|
|
305
|
+
const maxId = ids.length ? Math.max(...ids) : 0;
|
|
306
|
+
if (this._nextPreKeyId <= maxId) this._nextPreKeyId = maxId + 1;
|
|
307
|
+
if (this._firstUnuploadedPreKeyId > this._nextPreKeyId) {
|
|
308
|
+
this._firstUnuploadedPreKeyId = this._nextPreKeyId;
|
|
309
|
+
}
|
|
124
310
|
}
|
|
125
311
|
|
|
126
312
|
// Persist a phone ↔ LID mapping. Called any time we learn a new mapping from
|
|
@@ -138,16 +324,96 @@ class SignalStore {
|
|
|
138
324
|
}
|
|
139
325
|
|
|
140
326
|
// Everything that is persisted, as it goes on disk.
|
|
327
|
+
//
|
|
328
|
+
// One-time pre-keys are not in here — they are a file each, see _preKeyFile().
|
|
329
|
+
// Their two counters are, because they have to survive a pool that has been
|
|
330
|
+
// drained to nothing and say what id comes next anyway.
|
|
141
331
|
_snapshot() {
|
|
142
332
|
return JSON.stringify({
|
|
143
333
|
sessions: this._sessions,
|
|
144
|
-
preKeys: this._preKeys,
|
|
145
334
|
signedPreKeys: this._signedPreKeys,
|
|
146
335
|
identities: this._identities,
|
|
147
|
-
lidMappings: this._lidMappings
|
|
336
|
+
lidMappings: this._lidMappings,
|
|
337
|
+
nextPreKeyId: this._nextPreKeyId,
|
|
338
|
+
firstUnuploadedPreKeyId: this._firstUnuploadedPreKeyId
|
|
148
339
|
});
|
|
149
340
|
}
|
|
150
341
|
|
|
342
|
+
// ─── Pre-key writing ──────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
// Mark one pre-key id as needing its file written (or removed, when the key
|
|
345
|
+
// is no longer in memory) and arm the debounce.
|
|
346
|
+
_savePreKey(id) {
|
|
347
|
+
if (!this._pkDir) return;
|
|
348
|
+
this._pkPending.add(String(id));
|
|
349
|
+
if (this._pkTimer) clearTimeout(this._pkTimer);
|
|
350
|
+
this._pkTimer = setTimeout(() => {
|
|
351
|
+
this._pkTimer = null;
|
|
352
|
+
this._flushPreKeysAsync();
|
|
353
|
+
}, DEBOUNCE_MS);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_flushPreKeysAsync() {
|
|
357
|
+
if (!this._pkDir || this._pkWriting || !this._pkPending.size) return;
|
|
358
|
+
try { fs.mkdirSync(this._pkDir, { recursive: true }); } catch (_) { return; }
|
|
359
|
+
|
|
360
|
+
this._pkWriting = true;
|
|
361
|
+
const ids = [...this._pkPending];
|
|
362
|
+
this._pkPending.clear();
|
|
363
|
+
|
|
364
|
+
let next = 0, active = 0, done = false;
|
|
365
|
+
const pump = () => {
|
|
366
|
+
while (active < PRE_KEY_WRITE_CONCURRENCY && next < ids.length) {
|
|
367
|
+
active++;
|
|
368
|
+
this._writePreKeyAsync(ids[next++], () => { active--; pump(); });
|
|
369
|
+
}
|
|
370
|
+
if (!active && next >= ids.length && !done) {
|
|
371
|
+
done = true;
|
|
372
|
+
this._pkWriting = false;
|
|
373
|
+
// Something came due while this batch was running — and cleared the
|
|
374
|
+
// debounce that would have picked it up, since _savePreKey only arms a
|
|
375
|
+
// timer. Arm one now.
|
|
376
|
+
if (this._pkPending.size) this._savePreKey([...this._pkPending][0]);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
pump();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_writePreKeyAsync(id, cb) {
|
|
383
|
+
if (!this._pkDir) return cb();
|
|
384
|
+
const file = this._preKeyFile(id);
|
|
385
|
+
const entry = this._preKeys[id];
|
|
386
|
+
if (!entry) return fs.unlink(file, () => cb());
|
|
387
|
+
|
|
388
|
+
let data;
|
|
389
|
+
try { data = _encodePreKeyFile(entry); } catch (_) { return cb(); }
|
|
390
|
+
fs.writeFile(file, data, 'utf8', () => {
|
|
391
|
+
// Consumed while the write was in flight. A key that has been used is one
|
|
392
|
+
// the account must not offer again, so take it back off the disk rather
|
|
393
|
+
// than leave the write that overtook the unlink standing.
|
|
394
|
+
if (!this._preKeys[id]) return fs.unlink(file, () => cb());
|
|
395
|
+
cb();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_flushPreKeysSync() {
|
|
400
|
+
if (!this._pkDir || !this._pkPending.size) return;
|
|
401
|
+
if (this._pkTimer) { clearTimeout(this._pkTimer); this._pkTimer = null; }
|
|
402
|
+
|
|
403
|
+
const ids = [...this._pkPending];
|
|
404
|
+
this._pkPending.clear();
|
|
405
|
+
try { fs.mkdirSync(this._pkDir, { recursive: true }); } catch (_) { return; }
|
|
406
|
+
|
|
407
|
+
for (const id of ids) {
|
|
408
|
+
const file = this._preKeyFile(id);
|
|
409
|
+
const entry = this._preKeys[id];
|
|
410
|
+
try {
|
|
411
|
+
if (entry) fs.writeFileSync(file, _encodePreKeyFile(entry), 'utf8');
|
|
412
|
+
else fs.unlinkSync(file);
|
|
413
|
+
} catch (_) {}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
151
417
|
// Where a write assembles the file before it becomes the file. The sync and
|
|
152
418
|
// async writers get separate names so the one can never find the other's
|
|
153
419
|
// half-written temp file under it.
|
|
@@ -166,6 +432,9 @@ class SignalStore {
|
|
|
166
432
|
|
|
167
433
|
// Immediate synchronous flush (process exit, and detach())
|
|
168
434
|
_flushSync() {
|
|
435
|
+
// Pre-keys have their own dirty set — a pool that changed while the
|
|
436
|
+
// snapshot did not still has files owed to it.
|
|
437
|
+
this._flushPreKeysSync();
|
|
169
438
|
if (!this._dirty || !this._filePath) return;
|
|
170
439
|
if (this._saveTimer) { clearTimeout(this._saveTimer); this._saveTimer = null; }
|
|
171
440
|
const rev = this._rev;
|
|
@@ -378,16 +647,20 @@ class SignalStore {
|
|
|
378
647
|
}
|
|
379
648
|
|
|
380
649
|
async storePreKey(keyId, keyPair) {
|
|
381
|
-
|
|
650
|
+
const id = String(keyId);
|
|
651
|
+
this._preKeys[id] = {
|
|
382
652
|
pubKey: Buffer.from(keyPair.pubKey).toString('base64'),
|
|
383
653
|
privKey: Buffer.from(keyPair.privKey).toString('base64')
|
|
384
654
|
};
|
|
385
|
-
this.
|
|
655
|
+
this._savePreKey(id);
|
|
656
|
+
// Storing an id spends it, whether it was generated here or restored from
|
|
657
|
+
// somewhere else.
|
|
658
|
+
this.setNextPreKeyId(Number(keyId) + 1);
|
|
386
659
|
}
|
|
387
660
|
|
|
388
661
|
async removePreKey(keyId) {
|
|
389
662
|
delete this._preKeys[String(keyId)];
|
|
390
|
-
this.
|
|
663
|
+
this._savePreKey(keyId);
|
|
391
664
|
}
|
|
392
665
|
|
|
393
666
|
// ─── Signed PreKeys ───────────────────────────────────────────────────────
|
|
@@ -420,9 +693,36 @@ class SignalStore {
|
|
|
420
693
|
return Object.keys(this._preKeys).length;
|
|
421
694
|
}
|
|
422
695
|
|
|
696
|
+
/** Whether this id is still in the pool — i.e. nobody has spent it yet. */
|
|
697
|
+
hasPreKey(keyId) {
|
|
698
|
+
return Object.prototype.hasOwnProperty.call(this._preKeys, String(keyId));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The id the next generated pre-key takes. Never goes backwards. */
|
|
423
702
|
nextPreKeyId() {
|
|
424
|
-
|
|
425
|
-
|
|
703
|
+
return this._nextPreKeyId;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
setNextPreKeyId(id) {
|
|
707
|
+
const n = Number(id);
|
|
708
|
+
if (!Number.isFinite(n) || n <= this._nextPreKeyId) return;
|
|
709
|
+
this._nextPreKeyId = Math.floor(n);
|
|
710
|
+
if (this._firstUnuploadedPreKeyId > this._nextPreKeyId) {
|
|
711
|
+
this._firstUnuploadedPreKeyId = this._nextPreKeyId;
|
|
712
|
+
}
|
|
713
|
+
this._save();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** The lowest id the server has not been sent. Never goes backwards. */
|
|
717
|
+
firstUnuploadedPreKeyId() {
|
|
718
|
+
return this._firstUnuploadedPreKeyId;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
setFirstUnuploadedPreKeyId(id) {
|
|
722
|
+
const n = Number(id);
|
|
723
|
+
if (!Number.isFinite(n) || n <= this._firstUnuploadedPreKeyId) return;
|
|
724
|
+
this._firstUnuploadedPreKeyId = Math.min(Math.floor(n), this._nextPreKeyId);
|
|
725
|
+
this._save();
|
|
426
726
|
}
|
|
427
727
|
}
|
|
428
728
|
|