whalibmob 5.14.3 → 5.14.5

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
@@ -202,7 +202,12 @@ function enableWireTrace() {
202
202
  return origWrite(chunk, ...rest);
203
203
  };
204
204
 
205
- sock.on('data', (d) => {
205
+ // Prepended, not appended: the socket already carries the handshake's own
206
+ // data listener, registered while connect() was building the socket. An
207
+ // appended one runs after the frame has been read, so a frame the server
208
+ // sent would print *after* the error it caused — or not at all, once the
209
+ // failure tears the connection down. The trace has to come first.
210
+ sock.prependListener('data', (d) => {
206
211
  if (this.secured) return;
207
212
  // Label positionally, but say so when the reply is plainly not a Noise
208
213
  // frame — a proxy or captive portal answering in ASCII is worth reading
@@ -1148,10 +1153,19 @@ function requireConn() {
1148
1153
  // ─── tokenizer ────────────────────────────────────────────────────────────────
1149
1154
 
1150
1155
  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;
1156
+ const s = String(line || '');
1157
+ const t = []; let b = '';
1158
+ for (let i = 0; i < s.length; i++) {
1159
+ const c = s[i];
1160
+ // A quote groups only when it opens an argument and is actually closed
1161
+ // later in the line. Anything else is text the user typed — an apostrophe
1162
+ // inside a word, an unterminated quote — and message bodies go out
1163
+ // verbatim, so it has to survive as itself.
1164
+ if ((c === '"' || c === "'") && b === '') {
1165
+ const end = s.indexOf(c, i + 1);
1166
+ if (end !== -1) { b += s.slice(i + 1, end); i = end; continue; }
1167
+ }
1168
+ if (c === ' ') { if (b) { t.push(b); b = ''; } } else b += c;
1155
1169
  }
1156
1170
  if (b) t.push(b);
1157
1171
  return t;
@@ -1246,13 +1260,19 @@ async function handleLine(line) {
1246
1260
  out('disconnected');
1247
1261
  break;
1248
1262
 
1249
- case '/reconnect':
1263
+ case '/reconnect': {
1250
1264
  if (!_client) { fail('not connected'); break; }
1265
+ // A companion session has no primary registration of its own, so it can
1266
+ // only come back the way it was linked. Read the mode before the
1267
+ // disconnect, while the client that knows it is still the current one.
1268
+ const web = _client._mode === 'web';
1251
1269
  _client.disconnect();
1252
1270
  await new Promise(r => setTimeout(r, 1200));
1253
1271
  out('reconnecting...');
1254
- await doConnect(_phone);
1272
+ if (web) await doConnectWeb(_phone);
1273
+ else await doConnect(_phone);
1255
1274
  break;
1275
+ }
1256
1276
 
1257
1277
  // ── messaging ──────────────────────────────────────────────────────────
1258
1278
 
@@ -1617,7 +1637,15 @@ async function handleLine(line) {
1617
1637
  requireConn();
1618
1638
  const jid = normalizeJid(p[1]);
1619
1639
  const ids = p.slice(2);
1620
- if (!jid || !ids.length) { fail('usage: /read <jid> <msgId...>'); break; }
1640
+ if (!jid) { fail('usage: /read <jid> [msgId...]'); break; }
1641
+ // Two different requests share the name: receipts for named messages,
1642
+ // and the whole chat. Without message ids there is nothing to send a
1643
+ // receipt for, so the chat itself is what gets marked.
1644
+ if (!ids.length) {
1645
+ await _client.markChatRead(jid);
1646
+ out('marked read');
1647
+ break;
1648
+ }
1621
1649
  _client.markRead(jid, ids);
1622
1650
  out('marked read ' + ids.length + ' message(s) in ' + jid);
1623
1651
  break;
@@ -1647,13 +1675,6 @@ async function handleLine(line) {
1647
1675
 
1648
1676
  // ── chats ──────────────────────────────────────────────────────────────
1649
1677
 
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
1678
  case '/restriction':
1658
1679
  case '/limit': {
1659
1680
  // --demo feeds a made-up restriction into this session and counts it
@@ -1824,7 +1845,15 @@ async function handleLine(line) {
1824
1845
 
1825
1846
  case '/ephemeral': {
1826
1847
  requireConn();
1827
- const jid = normalizeJid(p[1]) || asGroupJid(p[1]);
1848
+ // The timer is set with a different stanza for a group than for a DM,
1849
+ // so the domain decides which one goes out. A bare id has none, and
1850
+ // normalizeJid answers the user domain for anything it is given, so a
1851
+ // group id has to be recognised by its shape first: either the legacy
1852
+ // "<creator>-<created>" form or the 120363… ids groups are issued now.
1853
+ const raw = p[1] || '';
1854
+ const isGroupId = /@g\.us$/.test(raw) ||
1855
+ (!raw.includes('@') && (/^\d+-\d+$/.test(raw) || /^120363\d*$/.test(raw)));
1856
+ const jid = isGroupId ? asGroupJid(raw) : normalizeJid(raw);
1828
1857
  const sec = p[2];
1829
1858
  if (!jid || sec === undefined) {
1830
1859
  fail('usage: /ephemeral <jid> <seconds> (0=off 86400=1d 604800=1w 7776000=90d)');
@@ -2788,7 +2817,10 @@ async function main() {
2788
2817
  process.exit(1);
2789
2818
  }
2790
2819
  if (!fs.existsSync(_sessDir)) fs.mkdirSync(_sessDir, { recursive: true });
2791
- const sessFile = path.join(_sessDir, `${ph}.json`);
2820
+ // Same file the shell's /reg code writes: a number already filed loose in
2821
+ // the base directory stays there, a new one gets a directory of its own.
2822
+ sessionDirFor(_sessDir, ph, { create: true });
2823
+ const sessFile = storeFileFor(_sessDir, ph);
2792
2824
  let store = loadStore(sessFile);
2793
2825
  if (!store) {
2794
2826
  // Brand new — generate fresh keys, save immediately, no need to check /exist
@@ -2834,7 +2866,7 @@ async function main() {
2834
2866
  const code = flags.code;
2835
2867
  if (!ph) { fail('phone number required'); process.exit(1); }
2836
2868
  if (!code) { fail('--code is required'); process.exit(1); }
2837
- const file = path.join(_sessDir, `${ph}.json`);
2869
+ const file = storeFileFor(_sessDir, ph);
2838
2870
  const store = loadStore(file) || initAuthCreds(ph);
2839
2871
  out('verifying code for +' + ph + '...');
2840
2872
  try {
@@ -2844,8 +2876,11 @@ async function main() {
2844
2876
  const finalStore = r.store || store;
2845
2877
  finalStore.registered = true;
2846
2878
  finalStore.codePending = false;
2879
+ // The account can come back filed under a different form of the
2880
+ // number, and that is the one the session belongs to.
2847
2881
  const savedPhone = String(finalStore.phoneNumber || ph);
2848
- const savedFile = path.join(_sessDir, `${savedPhone}.json`);
2882
+ sessionDirFor(_sessDir, savedPhone, { create: true });
2883
+ const savedFile = storeFileFor(_sessDir, savedPhone);
2849
2884
  saveStore(finalStore, savedFile);
2850
2885
  if (r.canonicalPhoneNumber) {
2851
2886
  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);
@@ -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,19 +79,97 @@ 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');
107
+ }
108
+ }
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');
68
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);
69
173
  }
70
174
 
71
175
  // ─── ClientPayload enum values ───────────────────────────────────────────────
@@ -172,28 +276,50 @@ 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
 
304
+ // ─── Handshake trace ─────────────────────────────────────────────────────────
305
+ //
306
+ // The handshake runs before there are stanzas to log, so without this the debug
307
+ // output jumps from "connecting" to either a secured channel or an error with
308
+ // nothing in between — and when it fails, nothing to say which step it got to.
309
+ //
310
+ // Only public material is rendered: ephemeral and static public keys, frame and
311
+ // field sizes, certificate outcome. Derived session keys and anything private
312
+ // are named but never printed, because a debug trace is the thing people paste
313
+ // into a bug report.
314
+ function _hsKey(buf) {
315
+ if (!Buffer.isBuffer(buf)) return String(buf);
316
+ return buf.toString('hex') + ' (' + buf.length + 'B)';
317
+ }
318
+
319
+ function _hsSize(buf) {
320
+ return Buffer.isBuffer(buf) ? buf.length + 'B' : 'absent';
321
+ }
322
+
197
323
  // ─── NoiseSocket ─────────────────────────────────────────────────────────────
198
324
  //
199
325
  // Events emitted:
@@ -288,9 +414,18 @@ class NoiseSocket extends EventEmitter {
288
414
  };
289
415
  this.noiseState.hash(this._ephemeralKeyPair.public);
290
416
 
417
+ _whaDbg('[DBG] NOISE handshake begin transport=' + this.transport +
418
+ ' prologue=' + this._prologue.toString('hex') +
419
+ ' pattern=Noise_XX_25519_AESGCM_SHA256');
420
+ _whaDbg('[DBG] NOISE ──▶ ClientHello ephemeral=' + _hsKey(this._ephemeralKeyPair.public));
421
+
291
422
  const helloPayload = encodeHandshakeClientHello(this._ephemeralKeyPair.public);
292
423
  const frame = Buffer.concat([this._prologue, makeFrame(helloPayload)]);
424
+ _whaDbg('[DBG] NOISE ──▶ ClientHello ' + frame.length + 'B on the wire = prologue ' +
425
+ this._prologue.length + 'B + header ' + FRAME_HEADER_SIZE + 'B + payload ' +
426
+ helloPayload.length + 'B');
293
427
  this.socket.write(frame);
428
+ _whaDbg('[DBG] NOISE waiting for ServerHello');
294
429
  }
295
430
 
296
431
  _onData(data) {
@@ -301,11 +436,11 @@ class NoiseSocket extends EventEmitter {
301
436
 
302
437
  _processBuffer() {
303
438
  while (true) {
304
- if (this.rxBuf.length < 3) break;
439
+ if (this.rxBuf.length < FRAME_HEADER_SIZE) break;
305
440
  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);
441
+ if (this.rxBuf.length < FRAME_HEADER_SIZE + frameLen) break;
442
+ const frame = this.rxBuf.slice(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + frameLen);
443
+ this.rxBuf = this.rxBuf.slice(FRAME_HEADER_SIZE + frameLen);
309
444
 
310
445
  if (!this.secured) {
311
446
  this._handleHandshakeFrame(frame);
@@ -317,28 +452,46 @@ class NoiseSocket extends EventEmitter {
317
452
 
318
453
  _handleHandshakeFrame(frame) {
319
454
  try {
455
+ _whaDbg('[DBG] NOISE ◀── ServerHello ' + frame.length + 'B frame');
320
456
  const serverHello = decodeServerHello(frame);
457
+ _whaDbg('[DBG] NOISE ◀── ServerHello ephemeral=' + _hsSize(serverHello.ephemeral) +
458
+ ' static=' + _hsSize(serverHello.staticEnc) + ' encrypted' +
459
+ ' certificate=' + _hsSize(serverHello.payload) + ' encrypted');
321
460
  if (!serverHello.ephemeral) throw new Error('No server ephemeral in ServerHello');
461
+ _whaDbg('[DBG] NOISE ◀── server ephemeral=' + _hsKey(serverHello.ephemeral));
322
462
 
323
463
  this.noiseState.hash(serverHello.ephemeral);
324
464
  const sharedEE = dhShared(this._ephemeralKeyPair.private, serverHello.ephemeral);
325
465
  this.noiseState.mixKey(sharedEE);
466
+ _whaDbg('[DBG] NOISE mixed DH(our ephemeral, server ephemeral)');
326
467
 
327
468
  const serverStaticDec = this.noiseState.decryptWithAd(serverHello.staticEnc);
469
+ _whaDbg('[DBG] NOISE ◀── server static key decrypted=' + _hsKey(serverStaticDec));
328
470
  const sharedSE = dhShared(this._ephemeralKeyPair.private, serverStaticDec);
329
471
  this.noiseState.mixKey(sharedSE);
472
+ _whaDbg('[DBG] NOISE mixed DH(our ephemeral, server static)');
330
473
 
331
- if (serverHello.payload) {
332
- const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
333
- validateServerCert(certPlain);
474
+ // The server is authenticated here, before ClientFinish hands it our
475
+ // static key and payload. A ServerHello without a certificate leaves
476
+ // nothing to authenticate it with, so it does not get that far.
477
+ if (!serverHello.payload) {
478
+ throw new Error('WA server cert validation failed: the server sent no certificate');
334
479
  }
480
+ const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
481
+ _whaDbg('[DBG] NOISE ◀── certificate decrypted=' + certPlain.length + 'B — verifying');
482
+ validateServerCert(certPlain, serverStaticDec);
483
+ _whaDbg('[DBG] NOISE ◀── certificate verified: signed by the root CA and ' +
484
+ 'certifying the static key above — server authenticated');
335
485
 
336
486
  const noisePriv = this.store.noiseKeyPair.private;
337
487
  // Send the raw 32-byte noise public key (no 0x05 Signal prefix)
338
488
  const noisePub = stripKeyPrefix(this.store.noiseKeyPair.public);
339
489
  const encStatic = this.noiseState.encryptWithAd(noisePub);
490
+ _whaDbg('[DBG] NOISE ──▶ our static key=' + _hsKey(noisePub) +
491
+ ' encrypted to ' + encStatic.length + 'B');
340
492
  const sharedSS = dhShared(noisePriv, serverHello.ephemeral);
341
493
  this.noiseState.mixKey(sharedSS);
494
+ _whaDbg('[DBG] NOISE mixed DH(our static, server ephemeral)');
342
495
 
343
496
  // Web mode hands us a finished companion payload; there is no phone
344
497
  // identity to derive anything from, and the fields below do not exist on
@@ -346,6 +499,9 @@ class NoiseSocket extends EventEmitter {
346
499
  if (this._buildPayload) {
347
500
  const encPayload = this.noiseState.encryptWithAd(this._buildPayload());
348
501
  const finish = encodeHandshakeClientFinish(encStatic, encPayload);
502
+ _whaDbg('[DBG] NOISE ──▶ ClientFinish static=' + encStatic.length + 'B payload=' +
503
+ encPayload.length + 'B (companion) ' + (finish.length + FRAME_HEADER_SIZE) +
504
+ 'B on the wire');
349
505
  this.socket.write(makeFrame(finish));
350
506
 
351
507
  const webKeys = this.noiseState.split();
@@ -354,6 +510,7 @@ class NoiseSocket extends EventEmitter {
354
510
  this.writeCounter = 0;
355
511
  this.readCounter = 0;
356
512
  this.secured = true;
513
+ _whaDbg('[DBG] NOISE split() derived the send and receive keys — channel secured');
357
514
 
358
515
  if (this._expectSuccess) {
359
516
  this._awaitingAuth = true;
@@ -449,6 +606,9 @@ class NoiseSocket extends EventEmitter {
449
606
 
450
607
  const encPayload = this.noiseState.encryptWithAd(payload);
451
608
  const finish = encodeHandshakeClientFinish(encStatic, encPayload);
609
+ _whaDbg('[DBG] NOISE ──▶ ClientFinish static=' + encStatic.length + 'B payload=' +
610
+ encPayload.length + 'B (username=' + this.store.phoneNumber + ' version=' +
611
+ _version + ') ' + (finish.length + FRAME_HEADER_SIZE) + 'B on the wire');
452
612
  this.socket.write(makeFrame(finish));
453
613
 
454
614
  const keys = this.noiseState.split();
@@ -456,6 +616,8 @@ class NoiseSocket extends EventEmitter {
456
616
  this.readKey = keys.readKey;
457
617
  this.writeCounter = 0;
458
618
  this.readCounter = 0;
619
+ _whaDbg('[DBG] NOISE split() derived the send and receive keys — channel secured');
620
+ _whaDbg('[DBG] NOISE waiting for the server to accept the payload');
459
621
  this.secured = true;
460
622
 
461
623
  // Wait for the server's <success> or <failure> confirmation before
@@ -464,6 +626,8 @@ class NoiseSocket extends EventEmitter {
464
626
  this._awaitingAuth = true;
465
627
 
466
628
  } catch (err) {
629
+ // The step that failed is the one after the last line traced above.
630
+ _whaDbg('[DBG] NOISE ✗ handshake failed: ' + (err && err.message));
467
631
  if (this._connectReject) {
468
632
  this._connectReject(err);
469
633
  this._connectResolve = null;
@@ -485,6 +649,8 @@ class NoiseSocket extends EventEmitter {
485
649
  if (this._awaitingAuth) {
486
650
  this._awaitingAuth = false;
487
651
  const tag = node && node.description;
652
+ _whaDbg('[DBG] NOISE ◀── <' + tag + '> — the first frame over the secured channel, ' +
653
+ 'and the server\'s answer on the payload');
488
654
 
489
655
  if (tag === 'success') {
490
656
  if (this._connectResolve) {
@@ -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.3",
3
+ "version": "5.14.5",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API and web ",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",