whalibmob 5.14.2 → 5.14.4
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/cli.js +48 -18
- package/lib/BinaryNode.js +4 -0
- package/lib/Client.js +63 -58
- package/lib/MediaService.js +26 -3
- package/lib/MediaThumbnail.js +7 -1
- package/lib/Registration.js +28 -4
- package/lib/WAUSync/USyncQuery.js +12 -5
- package/lib/appstate/AppStateSync.js +15 -2
- package/lib/argo/ArgoDecoder.js +5 -1
- package/lib/image/Bmp.js +58 -11
- package/lib/image/Jpeg.js +4 -0
- package/lib/messages/ReportingToken.js +5 -1
- package/lib/noise.js +149 -38
- package/lib/signal/SenderKey.js +13 -1
- package/lib/signal/SignalStore.js +13 -2
- package/lib/signal/libsignal/curve.js +2 -2
- package/lib/signal/libsignal/numeric_fingerprint.js +17 -6
- package/lib/signal/libsignal/session_builder.js +7 -2
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -1148,10 +1148,19 @@ function requireConn() {
|
|
|
1148
1148
|
// ─── tokenizer ────────────────────────────────────────────────────────────────
|
|
1149
1149
|
|
|
1150
1150
|
function tokens(line) {
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1151
|
+
const s = String(line || '');
|
|
1152
|
+
const t = []; let b = '';
|
|
1153
|
+
for (let i = 0; i < s.length; i++) {
|
|
1154
|
+
const c = s[i];
|
|
1155
|
+
// A quote groups only when it opens an argument and is actually closed
|
|
1156
|
+
// later in the line. Anything else is text the user typed — an apostrophe
|
|
1157
|
+
// inside a word, an unterminated quote — and message bodies go out
|
|
1158
|
+
// verbatim, so it has to survive as itself.
|
|
1159
|
+
if ((c === '"' || c === "'") && b === '') {
|
|
1160
|
+
const end = s.indexOf(c, i + 1);
|
|
1161
|
+
if (end !== -1) { b += s.slice(i + 1, end); i = end; continue; }
|
|
1162
|
+
}
|
|
1163
|
+
if (c === ' ') { if (b) { t.push(b); b = ''; } } else b += c;
|
|
1155
1164
|
}
|
|
1156
1165
|
if (b) t.push(b);
|
|
1157
1166
|
return t;
|
|
@@ -1246,13 +1255,19 @@ async function handleLine(line) {
|
|
|
1246
1255
|
out('disconnected');
|
|
1247
1256
|
break;
|
|
1248
1257
|
|
|
1249
|
-
case '/reconnect':
|
|
1258
|
+
case '/reconnect': {
|
|
1250
1259
|
if (!_client) { fail('not connected'); break; }
|
|
1260
|
+
// A companion session has no primary registration of its own, so it can
|
|
1261
|
+
// only come back the way it was linked. Read the mode before the
|
|
1262
|
+
// disconnect, while the client that knows it is still the current one.
|
|
1263
|
+
const web = _client._mode === 'web';
|
|
1251
1264
|
_client.disconnect();
|
|
1252
1265
|
await new Promise(r => setTimeout(r, 1200));
|
|
1253
1266
|
out('reconnecting...');
|
|
1254
|
-
await
|
|
1267
|
+
if (web) await doConnectWeb(_phone);
|
|
1268
|
+
else await doConnect(_phone);
|
|
1255
1269
|
break;
|
|
1270
|
+
}
|
|
1256
1271
|
|
|
1257
1272
|
// ── messaging ──────────────────────────────────────────────────────────
|
|
1258
1273
|
|
|
@@ -1617,7 +1632,15 @@ async function handleLine(line) {
|
|
|
1617
1632
|
requireConn();
|
|
1618
1633
|
const jid = normalizeJid(p[1]);
|
|
1619
1634
|
const ids = p.slice(2);
|
|
1620
|
-
if (!jid
|
|
1635
|
+
if (!jid) { fail('usage: /read <jid> [msgId...]'); break; }
|
|
1636
|
+
// Two different requests share the name: receipts for named messages,
|
|
1637
|
+
// and the whole chat. Without message ids there is nothing to send a
|
|
1638
|
+
// receipt for, so the chat itself is what gets marked.
|
|
1639
|
+
if (!ids.length) {
|
|
1640
|
+
await _client.markChatRead(jid);
|
|
1641
|
+
out('marked read');
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1621
1644
|
_client.markRead(jid, ids);
|
|
1622
1645
|
out('marked read ' + ids.length + ' message(s) in ' + jid);
|
|
1623
1646
|
break;
|
|
@@ -1647,13 +1670,6 @@ async function handleLine(line) {
|
|
|
1647
1670
|
|
|
1648
1671
|
// ── chats ──────────────────────────────────────────────────────────────
|
|
1649
1672
|
|
|
1650
|
-
case '/read':
|
|
1651
|
-
requireConn();
|
|
1652
|
-
if (!p[1]) { fail('usage: /read <jid>'); break; }
|
|
1653
|
-
await _client.markChatRead(normalizeJid(p[1]));
|
|
1654
|
-
out('marked read');
|
|
1655
|
-
break;
|
|
1656
|
-
|
|
1657
1673
|
case '/restriction':
|
|
1658
1674
|
case '/limit': {
|
|
1659
1675
|
// --demo feeds a made-up restriction into this session and counts it
|
|
@@ -1824,7 +1840,15 @@ async function handleLine(line) {
|
|
|
1824
1840
|
|
|
1825
1841
|
case '/ephemeral': {
|
|
1826
1842
|
requireConn();
|
|
1827
|
-
|
|
1843
|
+
// The timer is set with a different stanza for a group than for a DM,
|
|
1844
|
+
// so the domain decides which one goes out. A bare id has none, and
|
|
1845
|
+
// normalizeJid answers the user domain for anything it is given, so a
|
|
1846
|
+
// group id has to be recognised by its shape first: either the legacy
|
|
1847
|
+
// "<creator>-<created>" form or the 120363… ids groups are issued now.
|
|
1848
|
+
const raw = p[1] || '';
|
|
1849
|
+
const isGroupId = /@g\.us$/.test(raw) ||
|
|
1850
|
+
(!raw.includes('@') && (/^\d+-\d+$/.test(raw) || /^120363\d*$/.test(raw)));
|
|
1851
|
+
const jid = isGroupId ? asGroupJid(raw) : normalizeJid(raw);
|
|
1828
1852
|
const sec = p[2];
|
|
1829
1853
|
if (!jid || sec === undefined) {
|
|
1830
1854
|
fail('usage: /ephemeral <jid> <seconds> (0=off 86400=1d 604800=1w 7776000=90d)');
|
|
@@ -2788,7 +2812,10 @@ async function main() {
|
|
|
2788
2812
|
process.exit(1);
|
|
2789
2813
|
}
|
|
2790
2814
|
if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
|
|
2791
|
-
|
|
2815
|
+
// Same file the shell's /reg code writes: a number already filed loose in
|
|
2816
|
+
// the base directory stays there, a new one gets a directory of its own.
|
|
2817
|
+
sessionDirFor(_sessDir, ph, { create: true });
|
|
2818
|
+
const sessFile = storeFileFor(_sessDir, ph);
|
|
2792
2819
|
let store = loadStore(sessFile);
|
|
2793
2820
|
if (!store) {
|
|
2794
2821
|
// Brand new — generate fresh keys, save immediately, no need to check /exist
|
|
@@ -2834,7 +2861,7 @@ async function main() {
|
|
|
2834
2861
|
const code = flags.code;
|
|
2835
2862
|
if (!ph) { fail('phone number required'); process.exit(1); }
|
|
2836
2863
|
if (!code) { fail('--code is required'); process.exit(1); }
|
|
2837
|
-
const file =
|
|
2864
|
+
const file = storeFileFor(_sessDir, ph);
|
|
2838
2865
|
const store = loadStore(file) || initAuthCreds(ph);
|
|
2839
2866
|
out('verifying code for +' + ph + '...');
|
|
2840
2867
|
try {
|
|
@@ -2844,8 +2871,11 @@ async function main() {
|
|
|
2844
2871
|
const finalStore = r.store || store;
|
|
2845
2872
|
finalStore.registered = true;
|
|
2846
2873
|
finalStore.codePending = false;
|
|
2874
|
+
// The account can come back filed under a different form of the
|
|
2875
|
+
// number, and that is the one the session belongs to.
|
|
2847
2876
|
const savedPhone = String(finalStore.phoneNumber || ph);
|
|
2848
|
-
|
|
2877
|
+
sessionDirFor(_sessDir, savedPhone, { create: true });
|
|
2878
|
+
const savedFile = storeFileFor(_sessDir, savedPhone);
|
|
2849
2879
|
saveStore(finalStore, savedFile);
|
|
2850
2880
|
if (r.canonicalPhoneNumber) {
|
|
2851
2881
|
out('note: WhatsApp knows this account as +' + r.canonicalPhoneNumber +
|
package/lib/BinaryNode.js
CHANGED
|
@@ -88,6 +88,10 @@ function _writeValue(value, parts) {
|
|
|
88
88
|
_writeListSize(value.length, parts);
|
|
89
89
|
for (const item of value) _writeNode(item, parts);
|
|
90
90
|
} else if (value instanceof BinaryNode) {
|
|
91
|
+
// Node content always reads back as a list, so a lone child has to go out
|
|
92
|
+
// as a one-element list. Written bare, the child's own list header would be
|
|
93
|
+
// taken for this node's child count and the rest of the node misparsed.
|
|
94
|
+
_writeListSize(1, parts);
|
|
91
95
|
_writeNode(value, parts);
|
|
92
96
|
} else {
|
|
93
97
|
_writeString(String(value), parts);
|
package/lib/Client.js
CHANGED
|
@@ -2449,50 +2449,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2449
2449
|
: fromStr;
|
|
2450
2450
|
const recipientPhone = targetJid.split('@')[0].split(':')[0].split('.')[0];
|
|
2451
2451
|
|
|
2452
|
-
// The retry names the device under one address family, but a contact holds
|
|
2453
|
-
// a session under each: a Signal address is (user, device) with the server
|
|
2454
|
-
// stripped, and the LID user and the phone user are different strings, so
|
|
2455
|
-
// one session is not reachable under the other's name. Purging only the
|
|
2456
|
-
// family the receipt happened to arrive in left the other one in place —
|
|
2457
|
-
// the resend picked it straight back up and produced the same ciphertext
|
|
2458
|
-
// the device had just said it could not read, so the message stayed
|
|
2459
|
-
// undecryptable until something else rebuilt the session.
|
|
2460
|
-
//
|
|
2461
|
-
// This is what made a reply sent after a companion-device exchange stick on
|
|
2462
|
-
// "Waiting for this message" on the primary: the receipt came in one family,
|
|
2463
|
-
// the stale session sat in the other, and every resend re-used it.
|
|
2464
|
-
const recipientUsers = new Set([recipientPhone]);
|
|
2465
|
-
const mappedPn = this._lidToPn && this._lidToPn.get(recipientPhone);
|
|
2466
|
-
const mappedLid = this._pnToLid && this._pnToLid.get(recipientPhone);
|
|
2467
|
-
if (mappedPn) recipientUsers.add(mappedPn);
|
|
2468
|
-
if (mappedLid) recipientUsers.add(mappedLid);
|
|
2469
|
-
|
|
2470
2452
|
_whaDbg('[DBG] RETRY_RECV msgId=' + msgId + ' from=' + fromStr +
|
|
2471
|
-
'
|
|
2472
|
-
|
|
2473
|
-
// Delete all Signal sessions for the recipient's devices so next send creates fresh pkmsg
|
|
2474
|
-
const sigStore = this._signal && this._signal.store;
|
|
2475
|
-
if (sigStore && sigStore._sessions) {
|
|
2476
|
-
const sessions = sigStore._sessions;
|
|
2477
|
-
Object.keys(sessions).forEach(addr => {
|
|
2478
|
-
// Match on the user segment rather than a bare prefix: "4071.0" must
|
|
2479
|
-
// not be cleared by a retry for "407".
|
|
2480
|
-
const user = addr.slice(0, addr.lastIndexOf('.'));
|
|
2481
|
-
if (recipientUsers.has(user)) {
|
|
2482
|
-
_whaDbg('[DBG] RETRY deleting session for ' + addr);
|
|
2483
|
-
delete sessions[addr];
|
|
2484
|
-
}
|
|
2485
|
-
});
|
|
2486
|
-
}
|
|
2487
|
-
|
|
2488
|
-
// Clear device manager cache so we re-fetch devices on next send. LID device
|
|
2489
|
-
// lists are filed under a "lid:" key, so clearing the bare user only ever
|
|
2490
|
-
// dropped the phone-side list.
|
|
2491
|
-
if (this._devMgr) {
|
|
2492
|
-
const cacheKeys = [];
|
|
2493
|
-
for (const u of recipientUsers) cacheKeys.push(u, 'lid:' + u);
|
|
2494
|
-
this._devMgr.clearCache(cacheKeys);
|
|
2495
|
-
}
|
|
2453
|
+
' — clearing session + resending\n');
|
|
2454
|
+
this._purgeContactSessions(recipientPhone, 'RETRY');
|
|
2496
2455
|
|
|
2497
2456
|
// Re-send the message — will create fresh pre-key (pkmsg) sessions.
|
|
2498
2457
|
// A group resend must go back through the group path: it needs a fresh
|
|
@@ -2740,6 +2699,59 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2740
2699
|
this._retryPending.delete(msgId);
|
|
2741
2700
|
}
|
|
2742
2701
|
|
|
2702
|
+
// Drop every Signal session and cached device list a contact owns, so the
|
|
2703
|
+
// next send to them has to fetch a prekey bundle and open a fresh session.
|
|
2704
|
+
//
|
|
2705
|
+
// A contact holds a session under each address family: a Signal address is
|
|
2706
|
+
// (user, device) with the server stripped, and the LID user and the phone
|
|
2707
|
+
// user are different strings, so one session is not reachable under the
|
|
2708
|
+
// other's name. Clearing only the family whose name happened to be at hand
|
|
2709
|
+
// leaves the other in place, and the next send picks it straight back up —
|
|
2710
|
+
// which is how a message stayed undecryptable on a device that had already
|
|
2711
|
+
// said it could not read it.
|
|
2712
|
+
//
|
|
2713
|
+
// Device lists are filed the same way, LID ones under a "lid:" key, so both
|
|
2714
|
+
// spellings of both users have to go.
|
|
2715
|
+
//
|
|
2716
|
+
// Returns the set of users it resolved, for callers that want to log it.
|
|
2717
|
+
_purgeContactSessions(user, tag) {
|
|
2718
|
+
const label = tag || 'PURGE';
|
|
2719
|
+
const users = new Set([String(user)]);
|
|
2720
|
+
const mappedPn = this._lidToPn && this._lidToPn.get(user);
|
|
2721
|
+
const mappedLid = this._pnToLid && this._pnToLid.get(user);
|
|
2722
|
+
if (mappedPn) users.add(mappedPn);
|
|
2723
|
+
if (mappedLid) users.add(mappedLid);
|
|
2724
|
+
|
|
2725
|
+
const sigStore = this._signal && this._signal.store;
|
|
2726
|
+
if (sigStore && sigStore._sessions) {
|
|
2727
|
+
const sessions = sigStore._sessions;
|
|
2728
|
+
for (const addr of Object.keys(sessions)) {
|
|
2729
|
+
// Match on the address's user segment rather than a bare string
|
|
2730
|
+
// prefix: a purge for "407" must not take "4071.0" with it.
|
|
2731
|
+
const addrUser = addr.slice(0, addr.lastIndexOf('.'));
|
|
2732
|
+
if (users.has(addrUser)) {
|
|
2733
|
+
_whaDbg('[DBG] ' + label + ' deleting session for ' + addr);
|
|
2734
|
+
// Through the store rather than off the object it hands back: a bare
|
|
2735
|
+
// `delete` leaves the file alone and the store unmarked, so nothing
|
|
2736
|
+
// is written on the way out and a restart loads every purged session
|
|
2737
|
+
// straight back — the one case the purge exists to prevent. Not
|
|
2738
|
+
// awaited on purpose; the method's body is synchronous, so the
|
|
2739
|
+
// delete and the save have both happened by the time it returns.
|
|
2740
|
+
sigStore.deleteSession(addr);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
if (this._devMgr) {
|
|
2746
|
+
const cacheKeys = [];
|
|
2747
|
+
for (const u of users) cacheKeys.push(u, 'lid:' + u);
|
|
2748
|
+
this._devMgr.clearCache(cacheKeys);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
_whaDbg('[DBG] ' + label + ' purged users=[' + [...users].join(',') + ']');
|
|
2752
|
+
return users;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2743
2755
|
// Hold a sent message's plaintext so a retry receipt naming it can be
|
|
2744
2756
|
// answered. Both send paths file their entry here so the bound lives in one
|
|
2745
2757
|
// place instead of being repeated as a literal at each call site.
|
|
@@ -2972,23 +2984,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2972
2984
|
}
|
|
2973
2985
|
|
|
2974
2986
|
if (type === 'identity') {
|
|
2975
|
-
// Server push: a contact re-registered WhatsApp (new identity key / new
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2987
|
+
// Server push: a contact re-registered WhatsApp (new identity key / new
|
|
2988
|
+
// phone). Every session we hold with them is anchored in an identity key
|
|
2989
|
+
// that no longer exists, so all of them have to go — under both the
|
|
2990
|
+
// number and the LID, or the next send reaches for the survivor and the
|
|
2991
|
+
// contact receives ciphertext nothing on their side can open. A retry
|
|
2992
|
+
// receipt cannot rescue that: the session is not stale, it is dead.
|
|
2978
2993
|
const fromJid = String(attrs.from || '');
|
|
2979
2994
|
const fromPhone = fromJid.split('@')[0].split(':')[0];
|
|
2980
|
-
if (fromPhone
|
|
2981
|
-
|
|
2982
|
-
_whaDbg('[DBG] NOTIF_IDENTITY flushed device cache for re-registered ' + fromPhone);
|
|
2983
|
-
}
|
|
2984
|
-
if (fromPhone && this._signal && this._signal.store && this._signal.store._sessions) {
|
|
2985
|
-
const sessions = this._signal.store._sessions;
|
|
2986
|
-
let cleared = 0;
|
|
2987
|
-
for (const addr of Object.keys(sessions)) {
|
|
2988
|
-
if (addr.startsWith(fromPhone + '.')) { delete sessions[addr]; cleared++; }
|
|
2989
|
-
}
|
|
2990
|
-
_whaDbg('[DBG] NOTIF_IDENTITY cleared ' + cleared + ' Signal sessions for ' + fromPhone);
|
|
2991
|
-
}
|
|
2995
|
+
if (fromPhone) this._purgeContactSessions(fromPhone, 'NOTIF_IDENTITY');
|
|
2996
|
+
this.emit('identity_change', { jid: fromJid, user: fromPhone });
|
|
2992
2997
|
}
|
|
2993
2998
|
|
|
2994
2999
|
// Ack the notification
|
package/lib/MediaService.js
CHANGED
|
@@ -256,13 +256,36 @@ async function downloadMedia(url, mediaKey, keyName, opts) {
|
|
|
256
256
|
|
|
257
257
|
// The CDN applies the same Origin check on the way down as on the way up, so a
|
|
258
258
|
// companion has to identify itself as a browser here too.
|
|
259
|
-
|
|
259
|
+
//
|
|
260
|
+
// A file that has moved answers with a redirect, sometimes more than one. A
|
|
261
|
+
// loop, a redirect with nowhere to go, and an outright refusal all have to end
|
|
262
|
+
// as a rejection: an error page handed back as if it were the media only turns
|
|
263
|
+
// up later as a MAC failure in decryptMedia, which says nothing about why.
|
|
264
|
+
const MAX_REDIRECTS = 5;
|
|
265
|
+
|
|
266
|
+
function _httpGet(url, opts, depth) {
|
|
260
267
|
return new Promise((resolve, reject) => {
|
|
261
268
|
const lib = url.startsWith('https') ? https : http;
|
|
262
269
|
const web = !!(opts && opts.web);
|
|
263
270
|
lib.get(url, { headers: mediaHeaders(web) }, (res) => {
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
const status = res.statusCode || 0;
|
|
272
|
+
if (status === 301 || status === 302) {
|
|
273
|
+
res.resume(); // drop the redirect body
|
|
274
|
+
const location = res.headers && res.headers.location;
|
|
275
|
+
if (!location) {
|
|
276
|
+
return reject(new Error('httpGet: the server answered ' + status +
|
|
277
|
+
' without a Location header'));
|
|
278
|
+
}
|
|
279
|
+
const hops = (depth || 0) + 1;
|
|
280
|
+
if (hops > MAX_REDIRECTS) {
|
|
281
|
+
return reject(new Error('httpGet: more than ' + MAX_REDIRECTS +
|
|
282
|
+
' redirects'));
|
|
283
|
+
}
|
|
284
|
+
return _httpGet(location, opts, hops).then(resolve, reject);
|
|
285
|
+
}
|
|
286
|
+
if (status < 200 || status > 299) {
|
|
287
|
+
res.resume(); // an error page, not media
|
|
288
|
+
return reject(new Error('httpGet: the server answered ' + status));
|
|
266
289
|
}
|
|
267
290
|
const chunks = [];
|
|
268
291
|
res.on('data', c => chunks.push(c));
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -64,8 +64,14 @@ function probeImageSize(buf) {
|
|
|
64
64
|
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// BMP
|
|
67
|
+
// BMP — the 12-byte BITMAPCOREHEADER holds the size as two Int16, every
|
|
68
|
+
// later header as two Int32, so the header size decides where to read.
|
|
68
69
|
if (buf[0] === 0x42 && buf[1] === 0x4d && buf.length >= 26) {
|
|
70
|
+
const headerSize = buf.readUInt32LE(14);
|
|
71
|
+
if (headerSize < 12) return null;
|
|
72
|
+
if (headerSize === 12) {
|
|
73
|
+
return { width: buf.readInt16LE(18), height: Math.abs(buf.readInt16LE(20)) };
|
|
74
|
+
}
|
|
69
75
|
return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) };
|
|
70
76
|
}
|
|
71
77
|
|
package/lib/Registration.js
CHANGED
|
@@ -451,6 +451,16 @@ const TWO_DIGIT_CCS = new Set([
|
|
|
451
451
|
'95','98'
|
|
452
452
|
]);
|
|
453
453
|
|
|
454
|
+
// Every country we carry metadata for has to be splittable here, or parsePhone
|
|
455
|
+
// takes the wrong prefix and the registration token ends up computed over the
|
|
456
|
+
// wrong national number — which the server answers by refusing the code. The
|
|
457
|
+
// two lists above were maintained by hand and had already drifted, so the
|
|
458
|
+
// country table itself gets the last word.
|
|
459
|
+
for (const cc of Object.keys(COUNTRY_META)) {
|
|
460
|
+
if (cc.length === 3) THREE_DIGIT_CCS.add(cc);
|
|
461
|
+
else if (cc.length === 2) TWO_DIGIT_CCS.add(cc);
|
|
462
|
+
}
|
|
463
|
+
|
|
454
464
|
function parsePhone(phoneNumber) {
|
|
455
465
|
const str = String(phoneNumber).replace(/\D/g, '');
|
|
456
466
|
|
|
@@ -1639,6 +1649,10 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1639
1649
|
return false;
|
|
1640
1650
|
}
|
|
1641
1651
|
|
|
1652
|
+
// The retry policy below is "unknown error once, then give up", so this only
|
|
1653
|
+
// bites when the server keeps answering with a different reason each time.
|
|
1654
|
+
const MAX_CODE_REQUEST_ATTEMPTS = 5;
|
|
1655
|
+
|
|
1642
1656
|
async function requestSmsCode(store, method, opts) {
|
|
1643
1657
|
method = method || 'sms';
|
|
1644
1658
|
opts = opts || {};
|
|
@@ -1721,14 +1735,22 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1721
1735
|
throw new Error(`WhatsApp security block: "${body}" — use a residential IP/proxy`);
|
|
1722
1736
|
}
|
|
1723
1737
|
|
|
1724
|
-
// Same error twice in a row → give up
|
|
1725
|
-
|
|
1726
|
-
|
|
1738
|
+
// Same error twice in a row → give up. Compared even when the reason is
|
|
1739
|
+
// empty: a 200 carrying a non-JSON body leaves both status and reason
|
|
1740
|
+
// undefined, and skipping those would retry a real /code request forever.
|
|
1741
|
+
if (reason === lastReason) {
|
|
1742
|
+
throw new Error(`Registration error (${m}): ${reason || 'no status in response'} — raw: ${JSON.stringify(result)}`);
|
|
1727
1743
|
}
|
|
1728
1744
|
|
|
1729
1745
|
// First occurrence of unknown error → retry once
|
|
1730
1746
|
lastReason = reason;
|
|
1731
1747
|
attemptNum++;
|
|
1748
|
+
|
|
1749
|
+
// A server answering with a different reason every time would otherwise
|
|
1750
|
+
// keep this loop going indefinitely.
|
|
1751
|
+
if (attemptNum > MAX_CODE_REQUEST_ATTEMPTS) {
|
|
1752
|
+
throw new Error(`Registration error (${m}): giving up after ${MAX_CODE_REQUEST_ATTEMPTS} attempts — raw: ${JSON.stringify(result)}`);
|
|
1753
|
+
}
|
|
1732
1754
|
}
|
|
1733
1755
|
}
|
|
1734
1756
|
|
|
@@ -1777,7 +1799,9 @@ async function verifyCode(store, code, opts) {
|
|
|
1777
1799
|
const waVersion = await fetchWaVersion(_device);
|
|
1778
1800
|
store.version = waVersion;
|
|
1779
1801
|
store.device = _device;
|
|
1780
|
-
|
|
1802
|
+
// A verification code is all digits, so callers reasonably hand one over as a
|
|
1803
|
+
// number; coercing here keeps that from dying on .replace before we ask.
|
|
1804
|
+
const normalized = normalizeCodeResult(code);
|
|
1781
1805
|
|
|
1782
1806
|
const screen = currentVerifyScreen(store);
|
|
1783
1807
|
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
@@ -49,11 +49,9 @@ class USyncQuery {
|
|
|
49
49
|
sideList: []
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (listNode && Array.isArray(listNode.content)) {
|
|
56
|
-
queryResult.list = listNode.content.reduce((acc, node) => {
|
|
52
|
+
const parseUsers = (listNode) => {
|
|
53
|
+
if (!listNode || !Array.isArray(listNode.content)) return [];
|
|
54
|
+
return listNode.content.reduce((acc, node) => {
|
|
57
55
|
const id = node && node.attrs && (node.attrs.jid || node.attrs.id);
|
|
58
56
|
if (id) {
|
|
59
57
|
const data = Array.isArray(node.content)
|
|
@@ -74,6 +72,15 @@ class USyncQuery {
|
|
|
74
72
|
}
|
|
75
73
|
return acc;
|
|
76
74
|
}, []);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const usyncNode = findChild(result, 'usync');
|
|
78
|
+
|
|
79
|
+
if (usyncNode) {
|
|
80
|
+
queryResult.list = parseUsers(findChild(usyncNode, 'list'));
|
|
81
|
+
// side_list carries users too and is a normal part of a result —
|
|
82
|
+
// leaving it unparsed makes callers iterating sideList see nothing.
|
|
83
|
+
queryResult.sideList = parseUsers(findChild(usyncNode, 'side_list'));
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
return queryResult;
|
|
@@ -111,7 +111,13 @@ function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
|
|
|
111
111
|
};
|
|
112
112
|
|
|
113
113
|
for (const m of mutations) {
|
|
114
|
-
|
|
114
|
+
// The index MAC is what the value is filed under in the hash, so a record
|
|
115
|
+
// that arrived without one cannot be folded in at all — skip it the same
|
|
116
|
+
// way as any other malformed row rather than let it take the run down.
|
|
117
|
+
if (!m || !m.valueBlob || !m.keyId || !m.indexBlob || !m.indexBlob.length) {
|
|
118
|
+
skipped++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
115
121
|
const keys = keysFor(m.keyId);
|
|
116
122
|
|
|
117
123
|
const encContent = m.valueBlob.subarray(0, -32);
|
|
@@ -205,7 +211,14 @@ function decodePatches(name, patches, initial, getKey, getMutation, validateMacs
|
|
|
205
211
|
const rec = getKey(b64);
|
|
206
212
|
if (!rec || !rec.keyData) throw new MissingAppStateKeyError(b64);
|
|
207
213
|
const k = mutationKeys(rec.keyData);
|
|
208
|
-
|
|
214
|
+
// A mutation with no value blob has no value MAC to contribute, and the
|
|
215
|
+
// server computed this patch MAC over every one of them. Leaving it out
|
|
216
|
+
// is not a repair — the MAC below simply will not match, which is the
|
|
217
|
+
// right answer for a patch we cannot reconstruct, and it is reached by
|
|
218
|
+
// the ordinary macFailed path instead of by throwing mid-run.
|
|
219
|
+
const macs = p.mutations
|
|
220
|
+
.filter(m => m && m.valueBlob)
|
|
221
|
+
.map(m => m.valueBlob.subarray(-32));
|
|
209
222
|
if (!patchMac(p.snapshotMac, macs, state.version, name, k.patchMacKey)
|
|
210
223
|
.equals(p.patchMac || Buffer.alloc(0))) {
|
|
211
224
|
macFailed = true;
|
package/lib/argo/ArgoDecoder.js
CHANGED
|
@@ -106,9 +106,13 @@ class BlockQueue {
|
|
|
106
106
|
this.inline = inlineEverything;
|
|
107
107
|
this.next = 0;
|
|
108
108
|
this.byKey = new Map();
|
|
109
|
+
// Inlined, every value comes out of the core in the one order, so they
|
|
110
|
+
// share a single list of what has been read — a backreference counts
|
|
111
|
+
// through that list and would find nothing in a fresh one.
|
|
112
|
+
this.inlineBlock = { reader: core, seen: [] };
|
|
109
113
|
}
|
|
110
114
|
for(key) {
|
|
111
|
-
if (this.inline) return
|
|
115
|
+
if (this.inline) return this.inlineBlock;
|
|
112
116
|
if (!this.byKey.has(key)) {
|
|
113
117
|
const buf = this.blocks[this.next++];
|
|
114
118
|
if (buf === undefined) throw new Error('argo: message is missing a block for ' + key);
|
package/lib/image/Bmp.js
CHANGED
|
@@ -10,6 +10,20 @@ function isBmp(buf) {
|
|
|
10
10
|
return Buffer.isBuffer(buf) && buf.length > 26 && buf[0] === 0x42 && buf[1] === 0x4d;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// One channel out of a packed pixel. A mask says where the channel sits and how
|
|
14
|
+
// wide it is, and the width varies — 5-6-5 and 5-5-5 are both ordinary — so the
|
|
15
|
+
// value is scaled by its own maximum rather than shifted by a fixed amount.
|
|
16
|
+
function maskChannel(v, mask) {
|
|
17
|
+
if (!mask) return 0;
|
|
18
|
+
let shift = 0;
|
|
19
|
+
while (!((mask >>> shift) & 1)) shift++;
|
|
20
|
+
const bits = (mask >>> shift) >>> 0;
|
|
21
|
+
let width = 0;
|
|
22
|
+
while ((bits >>> width) & 1) width++;
|
|
23
|
+
const max = (1 << width) - 1;
|
|
24
|
+
return Math.round(((v >>> shift) & max) * 255 / max);
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
function decodeBmp(buf) {
|
|
14
28
|
if (!isBmp(buf)) return null;
|
|
15
29
|
|
|
@@ -36,6 +50,22 @@ function decodeBmp(buf) {
|
|
|
36
50
|
if (compression !== 0 && compression !== 3) return null; // RLE, JPEG, PNG
|
|
37
51
|
if (![1, 4, 8, 16, 24, 32].includes(bpp)) return null;
|
|
38
52
|
|
|
53
|
+
// BITFIELDS gives up the fixed channel layout and states the masks instead.
|
|
54
|
+
// They follow a 40-byte header, and a V4/V5 header holds them at that same
|
|
55
|
+
// place, so one offset covers both; the fourth mask is only there from V4 on.
|
|
56
|
+
let masks = null;
|
|
57
|
+
if (compression === 3 && (bpp === 16 || bpp === 32)) {
|
|
58
|
+
const m = 14 + 40;
|
|
59
|
+
if (m + 12 > buf.length) return null;
|
|
60
|
+
masks = {
|
|
61
|
+
r: buf.readUInt32LE(m),
|
|
62
|
+
g: buf.readUInt32LE(m + 4),
|
|
63
|
+
b: buf.readUInt32LE(m + 8),
|
|
64
|
+
a: (headerSize >= 56 && m + 16 <= buf.length) ? buf.readUInt32LE(m + 12) : 0
|
|
65
|
+
};
|
|
66
|
+
if (!masks.r && !masks.g && !masks.b) masks = null; // nothing usable stated
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
// The palette sits between the header and the pixel data.
|
|
40
70
|
const paletteStart = 14 + headerSize;
|
|
41
71
|
const entrySize = headerSize === 12 ? 3 : 4;
|
|
@@ -60,20 +90,37 @@ function decodeBmp(buf) {
|
|
|
60
90
|
if (idx >= paletteCount || p + 2 >= buf.length) { r = g = b = 0; }
|
|
61
91
|
else { b = buf[p]; g = buf[p + 1]; r = buf[p + 2]; }
|
|
62
92
|
} else if (bpp === 16) {
|
|
63
|
-
// 5-5-5 with the top bit unused, widened back out to eight bits each.
|
|
64
93
|
const v = buf.readUInt16LE(srcRow + x * 2);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
if (masks) {
|
|
95
|
+
r = maskChannel(v, masks.r);
|
|
96
|
+
g = maskChannel(v, masks.g);
|
|
97
|
+
b = maskChannel(v, masks.b);
|
|
98
|
+
if (masks.a) a = maskChannel(v, masks.a);
|
|
99
|
+
} else {
|
|
100
|
+
// 5-5-5 with the top bit unused, widened back out to eight bits each.
|
|
101
|
+
r = ((v >> 10) & 31) * 255 / 31;
|
|
102
|
+
g = ((v >> 5) & 31) * 255 / 31;
|
|
103
|
+
b = (v & 31) * 255 / 31;
|
|
104
|
+
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
|
105
|
+
}
|
|
69
106
|
} else {
|
|
70
107
|
const p = srcRow + x * (bpp / 8);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
if (bpp === 32 && masks) {
|
|
109
|
+
const v = buf.readUInt32LE(p);
|
|
110
|
+
r = maskChannel(v, masks.r);
|
|
111
|
+
g = maskChannel(v, masks.g);
|
|
112
|
+
b = maskChannel(v, masks.b);
|
|
113
|
+
// With no fourth mask the last byte is whatever the writer left
|
|
114
|
+
// there, which is the same position the plain layout reads.
|
|
115
|
+
a = masks.a ? maskChannel(v, masks.a) : buf[p + 3];
|
|
116
|
+
} else {
|
|
117
|
+
b = buf[p]; g = buf[p + 1]; r = buf[p + 2];
|
|
118
|
+
if (bpp === 32) {
|
|
119
|
+
const alpha = buf[p + 3];
|
|
120
|
+
// A 32-bit BMP often leaves the fourth byte at zero rather than
|
|
121
|
+
// meaning fully transparent; treat an all-zero channel as opaque.
|
|
122
|
+
a = alpha;
|
|
123
|
+
}
|
|
77
124
|
}
|
|
78
125
|
}
|
|
79
126
|
|
package/lib/image/Jpeg.js
CHANGED
|
@@ -79,6 +79,10 @@ class BitReader {
|
|
|
79
79
|
}
|
|
80
80
|
bit() {
|
|
81
81
|
if (this.left === 0) {
|
|
82
|
+
// The entropy-coded data ends at the marker. Anything asked for past it
|
|
83
|
+
// is padding: reading on would take the marker byte itself for pixels,
|
|
84
|
+
// and a truncated file would finish its last unit on noise.
|
|
85
|
+
if (this.marker) return 0;
|
|
82
86
|
if (this.pos >= this.buf.length) { this.marker = 0xd9; return 0; }
|
|
83
87
|
let b = this.buf[this.pos++];
|
|
84
88
|
if (b === 0xff) {
|
|
@@ -180,7 +180,11 @@ function extractReportingTokenContent(data, cfg) {
|
|
|
180
180
|
|
|
181
181
|
if (!fieldCfg) { i = valEnd; continue; }
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// Only a field with subfields of its own is walked into. One marked to
|
|
184
|
+
// keep every subfield has nothing to select, so it is copied whole below
|
|
185
|
+
// — walking it with no configured subfields would select nothing and
|
|
186
|
+
// drop the field entirely.
|
|
187
|
+
if (fieldCfg.children) {
|
|
184
188
|
const sub = extractReportingTokenContent(
|
|
185
189
|
data.subarray(valStart, valEnd), fieldCfg.children || EMPTY_MAP
|
|
186
190
|
);
|
package/lib/noise.js
CHANGED
|
@@ -13,15 +13,37 @@ const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
|
|
|
13
13
|
const { dbg: _whaDbg } = require('./logger');
|
|
14
14
|
const { parsePhone, getCountryMeta } = require('./Registration');
|
|
15
15
|
|
|
16
|
-
// ───
|
|
16
|
+
// ─── Server certificate validator ────────
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// ServerHello.payload carries the certificate that authenticates the static
|
|
19
|
+
// Noise key the server presented in this handshake. Two shapes travel on the
|
|
20
|
+
// wire, and they do not share a Details layout:
|
|
21
|
+
//
|
|
22
|
+
// flat NoiseCertificate — what a native mobile server returns
|
|
23
|
+
// field1 details, field2 signature (64 bytes, by the root CA)
|
|
24
|
+
// Details{ 1 serial, 2 issuer, 3 expires, 4 subject, 5 key }
|
|
25
|
+
//
|
|
26
|
+
// CertChain — what a companion server returns
|
|
27
|
+
// field1 leaf, field2 intermediate, each { 1 details, 2 signature }
|
|
28
|
+
// Details{ 1 serial, 2 issuerSerial, 3 key, 4 notBefore, 5 notAfter }
|
|
29
|
+
// the intermediate is signed by the root CA, the leaf by the intermediate
|
|
30
|
+
//
|
|
31
|
+
// Whichever shape arrives, two things decide whether the server is genuine:
|
|
32
|
+
// the signature must chain back to the root CA below, and the certified key
|
|
33
|
+
// must equal the static key from this handshake. That second check is the one
|
|
34
|
+
// that binds the certificate to this connection — a signature alone only
|
|
35
|
+
// proves the certificate is real, not that it belongs to whoever is talking.
|
|
36
|
+
// Neither check may be skipped, so every path out of here either validates or
|
|
37
|
+
// throws; there is no quiet return.
|
|
22
38
|
//
|
|
23
39
|
// All decoded manually to avoid a protobufjs dependency in noise.js.
|
|
24
40
|
|
|
41
|
+
// The root of trust. A certificate that does not chain back to this key did
|
|
42
|
+
// not come from WhatsApp.
|
|
43
|
+
const NOISE_ROOT_CA_PUBLIC_KEY = Buffer.from(
|
|
44
|
+
'142375574d0a587166aae71ebe516437c4a28b73e3695c6ce1f7f9545da8ee6b', 'hex'
|
|
45
|
+
);
|
|
46
|
+
|
|
25
47
|
function _pbReadVarint(buf, off) {
|
|
26
48
|
let v = 0, shift = 0;
|
|
27
49
|
while (off.pos < buf.length) {
|
|
@@ -46,6 +68,10 @@ function _pbReadFields(buf) {
|
|
|
46
68
|
const len = _pbReadVarint(buf, off);
|
|
47
69
|
fields[fn] = buf.slice(off.pos, off.pos + len);
|
|
48
70
|
off.pos += len;
|
|
71
|
+
} else if (wt === 5) {
|
|
72
|
+
off.pos += 4; // no message here uses fixed32/fixed64, but stepping over
|
|
73
|
+
} else if (wt === 1) {
|
|
74
|
+
off.pos += 8; // one costs nothing and keeps later fields readable
|
|
49
75
|
} else {
|
|
50
76
|
break; // unexpected wire type — stop parsing
|
|
51
77
|
}
|
|
@@ -53,21 +79,99 @@ function _pbReadFields(buf) {
|
|
|
53
79
|
return fields;
|
|
54
80
|
}
|
|
55
81
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
function _certFail(what) {
|
|
83
|
+
throw new Error('WA server cert validation failed: ' + what);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _certBytes(value, what) {
|
|
87
|
+
if (!Buffer.isBuffer(value) || value.length === 0) _certFail('missing ' + what);
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A public key travels either bare or behind the 0x05 Signal type byte. Accept
|
|
92
|
+
// both spellings so an encoding difference never reads as a forged key.
|
|
93
|
+
function _certKeyBytes(value, what) {
|
|
94
|
+
const key = _certBytes(value, what);
|
|
95
|
+
const bare = (key.length === 33 && key[0] === 0x05) ? key.slice(1) : key;
|
|
96
|
+
if (bare.length !== 32) _certFail(what + ' is ' + bare.length + ' bytes, expected 32');
|
|
97
|
+
return bare;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _certVerifySignature(signerKey, details, signature, what) {
|
|
101
|
+
const sig = _certBytes(signature, what + ' signature');
|
|
102
|
+
if (sig.length !== 64) {
|
|
103
|
+
_certFail(what + ' signature is ' + sig.length + ' bytes, expected 64');
|
|
104
|
+
}
|
|
105
|
+
if (!curveJs.verify(signerKey, details, sig)) {
|
|
106
|
+
_certFail(what + ' has an invalid signature');
|
|
68
107
|
}
|
|
69
108
|
}
|
|
70
109
|
|
|
110
|
+
// The certified key is what the rest of the handshake has been talking to.
|
|
111
|
+
function _certRequireBinding(certKey, serverStaticKey) {
|
|
112
|
+
if (Buffer.compare(certKey, serverStaticKey) !== 0) {
|
|
113
|
+
_certFail('certified key does not match the static key this server presented');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Flat NoiseCertificate: signed by the root CA, key in Details field 5.
|
|
118
|
+
function _validateFlatCert(cert, serverStaticKey) {
|
|
119
|
+
const details = _certBytes(cert[1], 'certificate details');
|
|
120
|
+
_certVerifySignature(NOISE_ROOT_CA_PUBLIC_KEY, details, cert[2], 'certificate');
|
|
121
|
+
|
|
122
|
+
const det = _pbReadFields(details);
|
|
123
|
+
_certRequireBinding(_certKeyBytes(det[5], 'certificate key'), serverStaticKey);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// CertChain: root CA signs the intermediate, the intermediate signs the leaf,
|
|
127
|
+
// and the leaf carries the key that must match the handshake.
|
|
128
|
+
function _validateCertChain(chain, serverStaticKey) {
|
|
129
|
+
const intermediate = _pbReadFields(_certBytes(chain[2], 'intermediate certificate'));
|
|
130
|
+
const intDetails = _certBytes(intermediate[1], 'intermediate certificate details');
|
|
131
|
+
_certVerifySignature(NOISE_ROOT_CA_PUBLIC_KEY, intDetails, intermediate[2],
|
|
132
|
+
'intermediate certificate');
|
|
133
|
+
|
|
134
|
+
const intDet = _pbReadFields(intDetails);
|
|
135
|
+
if (intDet[2] !== undefined && intDet[2] !== 0) {
|
|
136
|
+
_certFail('intermediate certificate was not issued by the root CA, issuerSerial=' + intDet[2]);
|
|
137
|
+
}
|
|
138
|
+
const intKey = _certKeyBytes(intDet[3], 'intermediate certificate key');
|
|
139
|
+
|
|
140
|
+
const leaf = _pbReadFields(_certBytes(chain[1], 'leaf certificate'));
|
|
141
|
+
const leafDetail = _certBytes(leaf[1], 'leaf certificate details');
|
|
142
|
+
_certVerifySignature(intKey, leafDetail, leaf[2], 'leaf certificate');
|
|
143
|
+
|
|
144
|
+
const leafDet = _pbReadFields(leafDetail);
|
|
145
|
+
if (leafDet[2] === undefined) _certFail('missing leaf certificate issuerSerial');
|
|
146
|
+
if (intDet[1] === undefined) _certFail('missing intermediate certificate serial');
|
|
147
|
+
if (leafDet[2] !== intDet[1]) {
|
|
148
|
+
_certFail('leaf certificate was not issued by the intermediate');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_certRequireBinding(_certKeyBytes(leafDet[3], 'leaf certificate key'), serverStaticKey);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// payloadBuf is the decrypted ServerHello.payload, serverStaticKey the static
|
|
155
|
+
// key decrypted out of the same ServerHello.
|
|
156
|
+
function validateServerCert(payloadBuf, serverStaticKey) {
|
|
157
|
+
if (!Buffer.isBuffer(payloadBuf) || payloadBuf.length === 0) {
|
|
158
|
+
_certFail('the server sent no certificate');
|
|
159
|
+
}
|
|
160
|
+
const staticKey = _certKeyBytes(serverStaticKey, 'server static key');
|
|
161
|
+
const fields = _pbReadFields(payloadBuf);
|
|
162
|
+
|
|
163
|
+
// Tell the two shapes apart by what sits in field 2: a flat certificate keeps
|
|
164
|
+
// its own 64-byte signature there, a chain keeps a whole nested certificate,
|
|
165
|
+
// which is always longer. Reading it off the bytes rather than off the
|
|
166
|
+
// transport means neither side can be validated against the wrong layout.
|
|
167
|
+
const flat = Buffer.isBuffer(fields[1]) &&
|
|
168
|
+
Buffer.isBuffer(fields[2]) &&
|
|
169
|
+
fields[2].length === 64;
|
|
170
|
+
|
|
171
|
+
if (flat) _validateFlatCert(fields, staticKey);
|
|
172
|
+
else _validateCertChain(fields, staticKey);
|
|
173
|
+
}
|
|
174
|
+
|
|
71
175
|
// ─── ClientPayload enum values ───────────────────────────────────────────────
|
|
72
176
|
//
|
|
73
177
|
// ClientPayload.ConnectType, ClientPayload.ConnectReason and
|
|
@@ -172,25 +276,28 @@ class NoiseState {
|
|
|
172
276
|
|
|
173
277
|
// ─── Frame helpers ────────────────────────────────────────────────────────────
|
|
174
278
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
279
|
+
// A frame is a 3-byte big-endian length followed by that many bytes, and the
|
|
280
|
+
// wire is symmetric: what we write is shaped exactly like what we read below.
|
|
281
|
+
// The only thing that travels outside this framing is the handshake prologue,
|
|
282
|
+
// which the first frame is concatenated onto rather than wrapped in.
|
|
283
|
+
const FRAME_HEADER_SIZE = 3;
|
|
284
|
+
const MAX_FRAME_LENGTH = 0xffffff; // what a 3-byte length can express
|
|
285
|
+
|
|
177
286
|
function makeFrame(payload) {
|
|
178
|
-
const len
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
header
|
|
184
|
-
header[
|
|
185
|
-
|
|
186
|
-
header[
|
|
187
|
-
header[5] = lss & 0xff;
|
|
287
|
+
const len = payload.length;
|
|
288
|
+
if (len > MAX_FRAME_LENGTH) {
|
|
289
|
+
// Silently truncating the length here would desync the stream for good.
|
|
290
|
+
throw new Error('Frame length ' + len + ' exceeds ' + MAX_FRAME_LENGTH);
|
|
291
|
+
}
|
|
292
|
+
const header = Buffer.alloc(FRAME_HEADER_SIZE);
|
|
293
|
+
header[0] = (len >>> 16) & 0xff;
|
|
294
|
+
header[1] = (len >>> 8) & 0xff;
|
|
295
|
+
header[2] = len & 0xff;
|
|
188
296
|
return Buffer.concat([header, payload]);
|
|
189
297
|
}
|
|
190
298
|
|
|
191
|
-
// messageLengthBuffer: always 3 bytes (server → client).
|
|
192
299
|
function readFrameLength(data) {
|
|
193
|
-
if (data.length <
|
|
300
|
+
if (data.length < FRAME_HEADER_SIZE) return -1;
|
|
194
301
|
return (data[0] << 16) | (data[1] << 8) | data[2];
|
|
195
302
|
}
|
|
196
303
|
|
|
@@ -301,11 +408,11 @@ class NoiseSocket extends EventEmitter {
|
|
|
301
408
|
|
|
302
409
|
_processBuffer() {
|
|
303
410
|
while (true) {
|
|
304
|
-
if (this.rxBuf.length <
|
|
411
|
+
if (this.rxBuf.length < FRAME_HEADER_SIZE) break;
|
|
305
412
|
const frameLen = readFrameLength(this.rxBuf);
|
|
306
|
-
if (this.rxBuf.length <
|
|
307
|
-
const frame = this.rxBuf.slice(
|
|
308
|
-
this.rxBuf = this.rxBuf.slice(
|
|
413
|
+
if (this.rxBuf.length < FRAME_HEADER_SIZE + frameLen) break;
|
|
414
|
+
const frame = this.rxBuf.slice(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + frameLen);
|
|
415
|
+
this.rxBuf = this.rxBuf.slice(FRAME_HEADER_SIZE + frameLen);
|
|
309
416
|
|
|
310
417
|
if (!this.secured) {
|
|
311
418
|
this._handleHandshakeFrame(frame);
|
|
@@ -328,10 +435,14 @@ class NoiseSocket extends EventEmitter {
|
|
|
328
435
|
const sharedSE = dhShared(this._ephemeralKeyPair.private, serverStaticDec);
|
|
329
436
|
this.noiseState.mixKey(sharedSE);
|
|
330
437
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
438
|
+
// The server is authenticated here, before ClientFinish hands it our
|
|
439
|
+
// static key and payload. A ServerHello without a certificate leaves
|
|
440
|
+
// nothing to authenticate it with, so it does not get that far.
|
|
441
|
+
if (!serverHello.payload) {
|
|
442
|
+
throw new Error('WA server cert validation failed: the server sent no certificate');
|
|
334
443
|
}
|
|
444
|
+
const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
|
|
445
|
+
validateServerCert(certPlain, serverStaticDec);
|
|
335
446
|
|
|
336
447
|
const noisePriv = this.store.noiseKeyPair.private;
|
|
337
448
|
// Send the raw 32-byte noise public key (no 0x05 Signal prefix)
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -90,7 +90,10 @@ class SenderKeyStore {
|
|
|
90
90
|
constructor() {
|
|
91
91
|
this._data = {};
|
|
92
92
|
this._filePath = null;
|
|
93
|
+
// _rev counts mutations so an async write that started before a mutation
|
|
94
|
+
// landed cannot clear _dirty on its behalf.
|
|
93
95
|
this._dirty = false;
|
|
96
|
+
this._rev = 0;
|
|
94
97
|
this._saveTimer = null;
|
|
95
98
|
|
|
96
99
|
// Detachable, for the same reason as in SignalStore: a replaced store must
|
|
@@ -159,13 +162,22 @@ class SenderKeyStore {
|
|
|
159
162
|
|
|
160
163
|
_save() {
|
|
161
164
|
this._dirty = true;
|
|
165
|
+
this._rev++;
|
|
162
166
|
if (!this._filePath) return;
|
|
163
167
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
164
168
|
this._saveTimer = setTimeout(() => {
|
|
165
169
|
this._saveTimer = null;
|
|
170
|
+
// The revision this write is a snapshot of.
|
|
171
|
+
const rev = this._rev;
|
|
166
172
|
const data = JSON.stringify(this._data, null, 2);
|
|
167
173
|
fs.mkdirSync(path.dirname(this._filePath), { recursive: true });
|
|
168
|
-
|
|
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', () => {
|
|
179
|
+
if (this._rev === rev) this._dirty = false;
|
|
180
|
+
});
|
|
169
181
|
}, _SK_DEBOUNCE_MS);
|
|
170
182
|
}
|
|
171
183
|
|
|
@@ -32,8 +32,10 @@ class SignalStore {
|
|
|
32
32
|
// a freshly serialised object) invalidates the entry by identity.
|
|
33
33
|
this._openSessionMemo = new Map();
|
|
34
34
|
|
|
35
|
-
// Debounce state
|
|
35
|
+
// Debounce state. _rev counts mutations so an async write that started
|
|
36
|
+
// before a mutation landed cannot clear _dirty on its behalf.
|
|
36
37
|
this._dirty = false;
|
|
38
|
+
this._rev = 0;
|
|
37
39
|
this._saveTimer = null;
|
|
38
40
|
|
|
39
41
|
// Flush synchronously on exit so no state is ever lost.
|
|
@@ -121,10 +123,13 @@ class SignalStore {
|
|
|
121
123
|
// Called on every in-memory mutation — replaces the old synchronous _save().
|
|
122
124
|
_save() {
|
|
123
125
|
this._dirty = true;
|
|
126
|
+
this._rev++;
|
|
124
127
|
if (!this._filePath) return;
|
|
125
128
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
126
129
|
this._saveTimer = setTimeout(() => {
|
|
127
130
|
this._saveTimer = null;
|
|
131
|
+
// The revision this write is a snapshot of.
|
|
132
|
+
const rev = this._rev;
|
|
128
133
|
const data = JSON.stringify({
|
|
129
134
|
sessions: this._sessions,
|
|
130
135
|
preKeys: this._preKeys,
|
|
@@ -134,7 +139,13 @@ class SignalStore {
|
|
|
134
139
|
});
|
|
135
140
|
const dir = path.dirname(this._filePath);
|
|
136
141
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
137
|
-
|
|
142
|
+
// Only clear the flag when nothing changed while the write was running.
|
|
143
|
+
// A mutation that arrived after the snapshot is not in `data`, and its
|
|
144
|
+
// own debounce timer may never fire — clearing here would make the
|
|
145
|
+
// exit-time _flushSync() skip it and lose it.
|
|
146
|
+
fs.writeFile(this._filePath, data, 'utf8', () => {
|
|
147
|
+
if (this._rev === rev) this._dirty = false;
|
|
148
|
+
});
|
|
138
149
|
}, DEBOUNCE_MS);
|
|
139
150
|
}
|
|
140
151
|
|
|
@@ -129,7 +129,7 @@ exports.calculateSignature = function(privKey, message) {
|
|
|
129
129
|
return Buffer.from(curveJs.sign(privKey, message));
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
exports.verifySignature = function(pubKey, msg, sig
|
|
132
|
+
exports.verifySignature = function(pubKey, msg, sig) {
|
|
133
133
|
pubKey = scrubPubKeyFormat(pubKey);
|
|
134
134
|
if (!pubKey || pubKey.byteLength != 32) {
|
|
135
135
|
throw new Error("Invalid public key");
|
|
@@ -140,5 +140,5 @@ exports.verifySignature = function(pubKey, msg, sig, isInit) {
|
|
|
140
140
|
if (!sig || sig.byteLength != 64) {
|
|
141
141
|
throw new Error("Invalid signature");
|
|
142
142
|
}
|
|
143
|
-
return
|
|
143
|
+
return curveJs.verify(pubKey, msg, sig);
|
|
144
144
|
};
|
|
@@ -4,8 +4,20 @@ const crypto = require('./crypto.js');
|
|
|
4
4
|
var VERSION = 0;
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
// Inputs arrive as ArrayBuffers (createFor requires them) and as strings for
|
|
8
|
+
// the identifiers, neither of which Buffer.concat or crypto.hash accept.
|
|
9
|
+
function toBuffer(value) {
|
|
10
|
+
if (Buffer.isBuffer(value)) return value;
|
|
11
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
12
|
+
if (ArrayBuffer.isView(value)) {
|
|
13
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
14
|
+
}
|
|
15
|
+
return Buffer.from(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
7
19
|
async function iterateHash(data, key, count) {
|
|
8
|
-
const combined =
|
|
20
|
+
const combined = Buffer.concat([toBuffer(data), toBuffer(key)]);
|
|
9
21
|
const result = crypto.hash(combined);
|
|
10
22
|
if (--count === 0) {
|
|
11
23
|
return result;
|
|
@@ -34,12 +46,11 @@ function getEncodedChunk(hash, offset) {
|
|
|
34
46
|
|
|
35
47
|
async function getDisplayStringFor(identifier, key, iterations) {
|
|
36
48
|
const bytes = Buffer.concat([
|
|
37
|
-
shortToArrayBuffer(VERSION),
|
|
38
|
-
key,
|
|
39
|
-
identifier
|
|
49
|
+
toBuffer(shortToArrayBuffer(VERSION)),
|
|
50
|
+
toBuffer(key),
|
|
51
|
+
toBuffer(identifier)
|
|
40
52
|
]);
|
|
41
|
-
const
|
|
42
|
-
const output = new Uint8Array(await iterateHash(arraybuf, key, iterations));
|
|
53
|
+
const output = new Uint8Array(await iterateHash(bytes, key, iterations));
|
|
43
54
|
return getEncodedChunk(output, 0) +
|
|
44
55
|
getEncodedChunk(output, 5) +
|
|
45
56
|
getEncodedChunk(output, 10) +
|
|
@@ -25,8 +25,13 @@ class SessionBuilder {
|
|
|
25
25
|
if (!await this.storage.isTrustedIdentity(this.addr.id, device.identityKey)) {
|
|
26
26
|
throw new errors.UntrustedIdentityKeyError(this.addr.id, device.identityKey);
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// The signed pre-key is what ties this bundle to the identity key we
|
|
29
|
+
// just decided to trust. Taking it unverified would let anyone who
|
|
30
|
+
// can answer for this address hand us a key of their choosing.
|
|
31
|
+
if (!curve.verifySignature(device.identityKey, device.signedPreKey.publicKey,
|
|
32
|
+
device.signedPreKey.signature)) {
|
|
33
|
+
throw new errors.PreKeyError('Signed pre-key signature does not verify against the identity key');
|
|
34
|
+
}
|
|
30
35
|
const baseKey = curve.generateKeyPair();
|
|
31
36
|
const devicePreKey = device.preKey && device.preKey.publicKey;
|
|
32
37
|
const session = await this.initSession(true, baseKey, undefined, device.identityKey,
|