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 CHANGED
@@ -1148,10 +1148,19 @@ function requireConn() {
1148
1148
  // ─── tokenizer ────────────────────────────────────────────────────────────────
1149
1149
 
1150
1150
  function tokens(line) {
1151
- const t = []; let b = '', q = false;
1152
- for (const c of line) {
1153
- if (c === '"' || c === "'") { q = !q; continue; }
1154
- if (c === ' ' && !q) { if (b) { t.push(b); b = ''; } } else b += c;
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 doConnect(_phone);
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 || !ids.length) { fail('usage: /read <jid> <msgId...>'); break; }
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
- const jid = normalizeJid(p[1]) || asGroupJid(p[1]);
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
- const sessFile = path.join(_sessDir, `${ph}.json`);
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 = path.join(_sessDir, `${ph}.json`);
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
- const savedFile = path.join(_sessDir, `${savedPhone}.json`);
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
- ' users=[' + [...recipientUsers].join(',') + '] — clearing session + resending\n');
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 phone).
2976
- // Their old Signal sessions are no longer valid — clear them and the device
2977
- // cache so the next send builds a fresh pkmsg session from scratch.
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 && this._devMgr) {
2981
- this._devMgr._dcDel([fromPhone]);
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
@@ -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
- function _httpGet(url, opts) {
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
- if (res.statusCode === 301 || res.statusCode === 302) {
265
- return _httpGet(res.headers.location, opts).then(resolve).catch(reject);
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));
@@ -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
 
@@ -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
- if (reason && reason === lastReason) {
1726
- throw new Error(`Registration error (${m}): ${reason} — raw: ${JSON.stringify(result)}`);
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
- const normalized = code.replace(/[\s\-]/g, '').replace(/\D/g, '');
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 usyncNode = findChild(result, 'usync');
53
- const listNode = usyncNode ? findChild(usyncNode, 'list') : undefined;
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
- if (!m || !m.valueBlob || !m.keyId) { skipped++; continue; }
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
- const macs = p.mutations.map(m => m.valueBlob.subarray(-32));
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;
@@ -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 { reader: this.core, seen: [] };
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
- r = ((v >> 10) & 31) * 255 / 31;
66
- g = ((v >> 5) & 31) * 255 / 31;
67
- b = (v & 31) * 255 / 31;
68
- r = Math.round(r); g = Math.round(g); b = Math.round(b);
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
- b = buf[p]; g = buf[p + 1]; r = buf[p + 2];
72
- if (bpp === 32) {
73
- const alpha = buf[p + 3];
74
- // A 32-bit BMP often leaves the fourth byte at zero rather than
75
- // meaning fully transparent; treat an all-zero channel as opaque.
76
- a = alpha;
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
- if (fieldCfg.m || fieldCfg.children) {
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
- // ─── CertChain validator ────────
16
+ // ─── Server certificate validator ────────
17
17
  //
18
- // Server payload inside ServerHello is a CertChain protobuf:
19
- // CertChain.field2 (intermediate) → NoiseCertificate
20
- // NoiseCertificate.field1 (details) → NoiseCertificate.Details
21
- // Details.field2 (issuerSerial) must === 0
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 validateServerCert(payloadBuf) {
57
- // payloadBuf is the decrypted ServerHello.payload (CertChain protobuf)
58
- const chain = _pbReadFields(payloadBuf);
59
- const intermediate = chain[2]; // field 2 = intermediate NoiseCertificate
60
- if (!Buffer.isBuffer(intermediate)) return; // no intermediate cert — skip
61
- const cert = _pbReadFields(intermediate);
62
- const details = cert[1]; // field 1 = details bytes
63
- if (!Buffer.isBuffer(details)) return;
64
- const det = _pbReadFields(details);
65
- const issuerSerial = det[2]; // field 2 = issuerSerial (uint32)
66
- if (issuerSerial !== undefined && issuerSerial !== 0) {
67
- throw new Error('WA server cert validation failed: issuerSerial=' + issuerSerial + ' expected 0');
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
- // writeRequestHeader: writes 6 bytes (4 for upper part + 2 for lower).
176
- // Used for ALL outgoing frames (client → server).
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 = payload.length;
179
- const header = Buffer.alloc(6);
180
- const mss = len >> 16;
181
- header[0] = (mss >> 24) & 0xff;
182
- header[1] = (mss >> 16) & 0xff;
183
- header[2] = (mss >> 8) & 0xff;
184
- header[3] = mss & 0xff;
185
- const lss = len & 0xffff;
186
- header[4] = (lss >> 8) & 0xff;
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 < 3) return -1;
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 < 3) break;
411
+ if (this.rxBuf.length < FRAME_HEADER_SIZE) break;
305
412
  const frameLen = readFrameLength(this.rxBuf);
306
- if (this.rxBuf.length < 3 + frameLen) break;
307
- const frame = this.rxBuf.slice(3, 3 + frameLen);
308
- this.rxBuf = this.rxBuf.slice(3 + frameLen);
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
- if (serverHello.payload) {
332
- const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
333
- validateServerCert(certPlain);
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)
@@ -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
- fs.writeFile(this._filePath, data, 'utf8', () => { this._dirty = false; });
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
- fs.writeFile(this._filePath, data, 'utf8', () => { this._dirty = false; });
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, isInit) {
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 isInit ? true : curveJs.verify(pubKey, msg, sig);
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 = (new Uint8Array(Buffer.concat([data, key]))).buffer;
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 arraybuf = (new Uint8Array(bytes)).buffer;
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
- curve.verifySignature(device.identityKey, device.signedPreKey.publicKey,
29
- device.signedPreKey.signature, true);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.14.2",
3
+ "version": "5.14.4",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",