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 +54 -19
- package/lib/BinaryNode.js +4 -0
- package/lib/MediaService.js +26 -3
- package/lib/MediaThumbnail.js +7 -1
- package/lib/Registration.js +28 -4
- package/lib/WAUSync/USyncQuery.js +12 -5
- package/lib/appstate/AppStateSync.js +15 -2
- package/lib/argo/ArgoDecoder.js +5 -1
- package/lib/image/Bmp.js +58 -11
- package/lib/image/Jpeg.js +4 -0
- package/lib/messages/ReportingToken.js +5 -1
- package/lib/noise.js +204 -38
- package/lib/signal/SenderKey.js +13 -1
- package/lib/signal/SignalStore.js +13 -2
- package/lib/signal/libsignal/curve.js +2 -2
- package/lib/signal/libsignal/numeric_fingerprint.js +17 -6
- package/lib/signal/libsignal/session_builder.js +7 -2
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -202,7 +202,12 @@ function enableWireTrace() {
|
|
|
202
202
|
return origWrite(chunk, ...rest);
|
|
203
203
|
};
|
|
204
204
|
|
|
205
|
-
|
|
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
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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);
|
package/lib/MediaService.js
CHANGED
|
@@ -256,13 +256,36 @@ async function downloadMedia(url, mediaKey, keyName, opts) {
|
|
|
256
256
|
|
|
257
257
|
// The CDN applies the same Origin check on the way down as on the way up, so a
|
|
258
258
|
// companion has to identify itself as a browser here too.
|
|
259
|
-
|
|
259
|
+
//
|
|
260
|
+
// A file that has moved answers with a redirect, sometimes more than one. A
|
|
261
|
+
// loop, a redirect with nowhere to go, and an outright refusal all have to end
|
|
262
|
+
// as a rejection: an error page handed back as if it were the media only turns
|
|
263
|
+
// up later as a MAC failure in decryptMedia, which says nothing about why.
|
|
264
|
+
const MAX_REDIRECTS = 5;
|
|
265
|
+
|
|
266
|
+
function _httpGet(url, opts, depth) {
|
|
260
267
|
return new Promise((resolve, reject) => {
|
|
261
268
|
const lib = url.startsWith('https') ? https : http;
|
|
262
269
|
const web = !!(opts && opts.web);
|
|
263
270
|
lib.get(url, { headers: mediaHeaders(web) }, (res) => {
|
|
264
|
-
|
|
265
|
-
|
|
271
|
+
const status = res.statusCode || 0;
|
|
272
|
+
if (status === 301 || status === 302) {
|
|
273
|
+
res.resume(); // drop the redirect body
|
|
274
|
+
const location = res.headers && res.headers.location;
|
|
275
|
+
if (!location) {
|
|
276
|
+
return reject(new Error('httpGet: the server answered ' + status +
|
|
277
|
+
' without a Location header'));
|
|
278
|
+
}
|
|
279
|
+
const hops = (depth || 0) + 1;
|
|
280
|
+
if (hops > MAX_REDIRECTS) {
|
|
281
|
+
return reject(new Error('httpGet: more than ' + MAX_REDIRECTS +
|
|
282
|
+
' redirects'));
|
|
283
|
+
}
|
|
284
|
+
return _httpGet(location, opts, hops).then(resolve, reject);
|
|
285
|
+
}
|
|
286
|
+
if (status < 200 || status > 299) {
|
|
287
|
+
res.resume(); // an error page, not media
|
|
288
|
+
return reject(new Error('httpGet: the server answered ' + status));
|
|
266
289
|
}
|
|
267
290
|
const chunks = [];
|
|
268
291
|
res.on('data', c => chunks.push(c));
|
package/lib/MediaThumbnail.js
CHANGED
|
@@ -64,8 +64,14 @@ function probeImageSize(buf) {
|
|
|
64
64
|
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// BMP
|
|
67
|
+
// BMP — the 12-byte BITMAPCOREHEADER holds the size as two Int16, every
|
|
68
|
+
// later header as two Int32, so the header size decides where to read.
|
|
68
69
|
if (buf[0] === 0x42 && buf[1] === 0x4d && buf.length >= 26) {
|
|
70
|
+
const headerSize = buf.readUInt32LE(14);
|
|
71
|
+
if (headerSize < 12) return null;
|
|
72
|
+
if (headerSize === 12) {
|
|
73
|
+
return { width: buf.readInt16LE(18), height: Math.abs(buf.readInt16LE(20)) };
|
|
74
|
+
}
|
|
69
75
|
return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) };
|
|
70
76
|
}
|
|
71
77
|
|
package/lib/Registration.js
CHANGED
|
@@ -451,6 +451,16 @@ const TWO_DIGIT_CCS = new Set([
|
|
|
451
451
|
'95','98'
|
|
452
452
|
]);
|
|
453
453
|
|
|
454
|
+
// Every country we carry metadata for has to be splittable here, or parsePhone
|
|
455
|
+
// takes the wrong prefix and the registration token ends up computed over the
|
|
456
|
+
// wrong national number — which the server answers by refusing the code. The
|
|
457
|
+
// two lists above were maintained by hand and had already drifted, so the
|
|
458
|
+
// country table itself gets the last word.
|
|
459
|
+
for (const cc of Object.keys(COUNTRY_META)) {
|
|
460
|
+
if (cc.length === 3) THREE_DIGIT_CCS.add(cc);
|
|
461
|
+
else if (cc.length === 2) TWO_DIGIT_CCS.add(cc);
|
|
462
|
+
}
|
|
463
|
+
|
|
454
464
|
function parsePhone(phoneNumber) {
|
|
455
465
|
const str = String(phoneNumber).replace(/\D/g, '');
|
|
456
466
|
|
|
@@ -1639,6 +1649,10 @@ async function assertRegistrationKeys(store, waVersion) {
|
|
|
1639
1649
|
return false;
|
|
1640
1650
|
}
|
|
1641
1651
|
|
|
1652
|
+
// The retry policy below is "unknown error once, then give up", so this only
|
|
1653
|
+
// bites when the server keeps answering with a different reason each time.
|
|
1654
|
+
const MAX_CODE_REQUEST_ATTEMPTS = 5;
|
|
1655
|
+
|
|
1642
1656
|
async function requestSmsCode(store, method, opts) {
|
|
1643
1657
|
method = method || 'sms';
|
|
1644
1658
|
opts = opts || {};
|
|
@@ -1721,14 +1735,22 @@ async function requestSmsCode(store, method, opts) {
|
|
|
1721
1735
|
throw new Error(`WhatsApp security block: "${body}" — use a residential IP/proxy`);
|
|
1722
1736
|
}
|
|
1723
1737
|
|
|
1724
|
-
// Same error twice in a row → give up
|
|
1725
|
-
|
|
1726
|
-
|
|
1738
|
+
// Same error twice in a row → give up. Compared even when the reason is
|
|
1739
|
+
// empty: a 200 carrying a non-JSON body leaves both status and reason
|
|
1740
|
+
// undefined, and skipping those would retry a real /code request forever.
|
|
1741
|
+
if (reason === lastReason) {
|
|
1742
|
+
throw new Error(`Registration error (${m}): ${reason || 'no status in response'} — raw: ${JSON.stringify(result)}`);
|
|
1727
1743
|
}
|
|
1728
1744
|
|
|
1729
1745
|
// First occurrence of unknown error → retry once
|
|
1730
1746
|
lastReason = reason;
|
|
1731
1747
|
attemptNum++;
|
|
1748
|
+
|
|
1749
|
+
// A server answering with a different reason every time would otherwise
|
|
1750
|
+
// keep this loop going indefinitely.
|
|
1751
|
+
if (attemptNum > MAX_CODE_REQUEST_ATTEMPTS) {
|
|
1752
|
+
throw new Error(`Registration error (${m}): giving up after ${MAX_CODE_REQUEST_ATTEMPTS} attempts — raw: ${JSON.stringify(result)}`);
|
|
1753
|
+
}
|
|
1732
1754
|
}
|
|
1733
1755
|
}
|
|
1734
1756
|
|
|
@@ -1777,7 +1799,9 @@ async function verifyCode(store, code, opts) {
|
|
|
1777
1799
|
const waVersion = await fetchWaVersion(_device);
|
|
1778
1800
|
store.version = waVersion;
|
|
1779
1801
|
store.device = _device;
|
|
1780
|
-
|
|
1802
|
+
// A verification code is all digits, so callers reasonably hand one over as a
|
|
1803
|
+
// number; coercing here keeps that from dying on .replace before we ask.
|
|
1804
|
+
const normalized = normalizeCodeResult(code);
|
|
1781
1805
|
|
|
1782
1806
|
const screen = currentVerifyScreen(store);
|
|
1783
1807
|
await sendFunnelLog(store, waVersion, screen, 'submit_code', 'submit_code_attempt');
|
|
@@ -49,11 +49,9 @@ class USyncQuery {
|
|
|
49
49
|
sideList: []
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (listNode && Array.isArray(listNode.content)) {
|
|
56
|
-
queryResult.list = listNode.content.reduce((acc, node) => {
|
|
52
|
+
const parseUsers = (listNode) => {
|
|
53
|
+
if (!listNode || !Array.isArray(listNode.content)) return [];
|
|
54
|
+
return listNode.content.reduce((acc, node) => {
|
|
57
55
|
const id = node && node.attrs && (node.attrs.jid || node.attrs.id);
|
|
58
56
|
if (id) {
|
|
59
57
|
const data = Array.isArray(node.content)
|
|
@@ -74,6 +72,15 @@ class USyncQuery {
|
|
|
74
72
|
}
|
|
75
73
|
return acc;
|
|
76
74
|
}, []);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const usyncNode = findChild(result, 'usync');
|
|
78
|
+
|
|
79
|
+
if (usyncNode) {
|
|
80
|
+
queryResult.list = parseUsers(findChild(usyncNode, 'list'));
|
|
81
|
+
// side_list carries users too and is a normal part of a result —
|
|
82
|
+
// leaving it unparsed makes callers iterating sideList see nothing.
|
|
83
|
+
queryResult.sideList = parseUsers(findChild(usyncNode, 'side_list'));
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
return queryResult;
|
|
@@ -111,7 +111,13 @@ function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
|
|
|
111
111
|
};
|
|
112
112
|
|
|
113
113
|
for (const m of mutations) {
|
|
114
|
-
|
|
114
|
+
// The index MAC is what the value is filed under in the hash, so a record
|
|
115
|
+
// that arrived without one cannot be folded in at all — skip it the same
|
|
116
|
+
// way as any other malformed row rather than let it take the run down.
|
|
117
|
+
if (!m || !m.valueBlob || !m.keyId || !m.indexBlob || !m.indexBlob.length) {
|
|
118
|
+
skipped++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
115
121
|
const keys = keysFor(m.keyId);
|
|
116
122
|
|
|
117
123
|
const encContent = m.valueBlob.subarray(0, -32);
|
|
@@ -205,7 +211,14 @@ function decodePatches(name, patches, initial, getKey, getMutation, validateMacs
|
|
|
205
211
|
const rec = getKey(b64);
|
|
206
212
|
if (!rec || !rec.keyData) throw new MissingAppStateKeyError(b64);
|
|
207
213
|
const k = mutationKeys(rec.keyData);
|
|
208
|
-
|
|
214
|
+
// A mutation with no value blob has no value MAC to contribute, and the
|
|
215
|
+
// server computed this patch MAC over every one of them. Leaving it out
|
|
216
|
+
// is not a repair — the MAC below simply will not match, which is the
|
|
217
|
+
// right answer for a patch we cannot reconstruct, and it is reached by
|
|
218
|
+
// the ordinary macFailed path instead of by throwing mid-run.
|
|
219
|
+
const macs = p.mutations
|
|
220
|
+
.filter(m => m && m.valueBlob)
|
|
221
|
+
.map(m => m.valueBlob.subarray(-32));
|
|
209
222
|
if (!patchMac(p.snapshotMac, macs, state.version, name, k.patchMacKey)
|
|
210
223
|
.equals(p.patchMac || Buffer.alloc(0))) {
|
|
211
224
|
macFailed = true;
|
package/lib/argo/ArgoDecoder.js
CHANGED
|
@@ -106,9 +106,13 @@ class BlockQueue {
|
|
|
106
106
|
this.inline = inlineEverything;
|
|
107
107
|
this.next = 0;
|
|
108
108
|
this.byKey = new Map();
|
|
109
|
+
// Inlined, every value comes out of the core in the one order, so they
|
|
110
|
+
// share a single list of what has been read — a backreference counts
|
|
111
|
+
// through that list and would find nothing in a fresh one.
|
|
112
|
+
this.inlineBlock = { reader: core, seen: [] };
|
|
109
113
|
}
|
|
110
114
|
for(key) {
|
|
111
|
-
if (this.inline) return
|
|
115
|
+
if (this.inline) return this.inlineBlock;
|
|
112
116
|
if (!this.byKey.has(key)) {
|
|
113
117
|
const buf = this.blocks[this.next++];
|
|
114
118
|
if (buf === undefined) throw new Error('argo: message is missing a block for ' + key);
|
package/lib/image/Bmp.js
CHANGED
|
@@ -10,6 +10,20 @@ function isBmp(buf) {
|
|
|
10
10
|
return Buffer.isBuffer(buf) && buf.length > 26 && buf[0] === 0x42 && buf[1] === 0x4d;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// One channel out of a packed pixel. A mask says where the channel sits and how
|
|
14
|
+
// wide it is, and the width varies — 5-6-5 and 5-5-5 are both ordinary — so the
|
|
15
|
+
// value is scaled by its own maximum rather than shifted by a fixed amount.
|
|
16
|
+
function maskChannel(v, mask) {
|
|
17
|
+
if (!mask) return 0;
|
|
18
|
+
let shift = 0;
|
|
19
|
+
while (!((mask >>> shift) & 1)) shift++;
|
|
20
|
+
const bits = (mask >>> shift) >>> 0;
|
|
21
|
+
let width = 0;
|
|
22
|
+
while ((bits >>> width) & 1) width++;
|
|
23
|
+
const max = (1 << width) - 1;
|
|
24
|
+
return Math.round(((v >>> shift) & max) * 255 / max);
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
function decodeBmp(buf) {
|
|
14
28
|
if (!isBmp(buf)) return null;
|
|
15
29
|
|
|
@@ -36,6 +50,22 @@ function decodeBmp(buf) {
|
|
|
36
50
|
if (compression !== 0 && compression !== 3) return null; // RLE, JPEG, PNG
|
|
37
51
|
if (![1, 4, 8, 16, 24, 32].includes(bpp)) return null;
|
|
38
52
|
|
|
53
|
+
// BITFIELDS gives up the fixed channel layout and states the masks instead.
|
|
54
|
+
// They follow a 40-byte header, and a V4/V5 header holds them at that same
|
|
55
|
+
// place, so one offset covers both; the fourth mask is only there from V4 on.
|
|
56
|
+
let masks = null;
|
|
57
|
+
if (compression === 3 && (bpp === 16 || bpp === 32)) {
|
|
58
|
+
const m = 14 + 40;
|
|
59
|
+
if (m + 12 > buf.length) return null;
|
|
60
|
+
masks = {
|
|
61
|
+
r: buf.readUInt32LE(m),
|
|
62
|
+
g: buf.readUInt32LE(m + 4),
|
|
63
|
+
b: buf.readUInt32LE(m + 8),
|
|
64
|
+
a: (headerSize >= 56 && m + 16 <= buf.length) ? buf.readUInt32LE(m + 12) : 0
|
|
65
|
+
};
|
|
66
|
+
if (!masks.r && !masks.g && !masks.b) masks = null; // nothing usable stated
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
// The palette sits between the header and the pixel data.
|
|
40
70
|
const paletteStart = 14 + headerSize;
|
|
41
71
|
const entrySize = headerSize === 12 ? 3 : 4;
|
|
@@ -60,20 +90,37 @@ function decodeBmp(buf) {
|
|
|
60
90
|
if (idx >= paletteCount || p + 2 >= buf.length) { r = g = b = 0; }
|
|
61
91
|
else { b = buf[p]; g = buf[p + 1]; r = buf[p + 2]; }
|
|
62
92
|
} else if (bpp === 16) {
|
|
63
|
-
// 5-5-5 with the top bit unused, widened back out to eight bits each.
|
|
64
93
|
const v = buf.readUInt16LE(srcRow + x * 2);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
if (masks) {
|
|
95
|
+
r = maskChannel(v, masks.r);
|
|
96
|
+
g = maskChannel(v, masks.g);
|
|
97
|
+
b = maskChannel(v, masks.b);
|
|
98
|
+
if (masks.a) a = maskChannel(v, masks.a);
|
|
99
|
+
} else {
|
|
100
|
+
// 5-5-5 with the top bit unused, widened back out to eight bits each.
|
|
101
|
+
r = ((v >> 10) & 31) * 255 / 31;
|
|
102
|
+
g = ((v >> 5) & 31) * 255 / 31;
|
|
103
|
+
b = (v & 31) * 255 / 31;
|
|
104
|
+
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
|
105
|
+
}
|
|
69
106
|
} else {
|
|
70
107
|
const p = srcRow + x * (bpp / 8);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
if (bpp === 32 && masks) {
|
|
109
|
+
const v = buf.readUInt32LE(p);
|
|
110
|
+
r = maskChannel(v, masks.r);
|
|
111
|
+
g = maskChannel(v, masks.g);
|
|
112
|
+
b = maskChannel(v, masks.b);
|
|
113
|
+
// With no fourth mask the last byte is whatever the writer left
|
|
114
|
+
// there, which is the same position the plain layout reads.
|
|
115
|
+
a = masks.a ? maskChannel(v, masks.a) : buf[p + 3];
|
|
116
|
+
} else {
|
|
117
|
+
b = buf[p]; g = buf[p + 1]; r = buf[p + 2];
|
|
118
|
+
if (bpp === 32) {
|
|
119
|
+
const alpha = buf[p + 3];
|
|
120
|
+
// A 32-bit BMP often leaves the fourth byte at zero rather than
|
|
121
|
+
// meaning fully transparent; treat an all-zero channel as opaque.
|
|
122
|
+
a = alpha;
|
|
123
|
+
}
|
|
77
124
|
}
|
|
78
125
|
}
|
|
79
126
|
|
package/lib/image/Jpeg.js
CHANGED
|
@@ -79,6 +79,10 @@ class BitReader {
|
|
|
79
79
|
}
|
|
80
80
|
bit() {
|
|
81
81
|
if (this.left === 0) {
|
|
82
|
+
// The entropy-coded data ends at the marker. Anything asked for past it
|
|
83
|
+
// is padding: reading on would take the marker byte itself for pixels,
|
|
84
|
+
// and a truncated file would finish its last unit on noise.
|
|
85
|
+
if (this.marker) return 0;
|
|
82
86
|
if (this.pos >= this.buf.length) { this.marker = 0xd9; return 0; }
|
|
83
87
|
let b = this.buf[this.pos++];
|
|
84
88
|
if (b === 0xff) {
|
|
@@ -180,7 +180,11 @@ function extractReportingTokenContent(data, cfg) {
|
|
|
180
180
|
|
|
181
181
|
if (!fieldCfg) { i = valEnd; continue; }
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
// Only a field with subfields of its own is walked into. One marked to
|
|
184
|
+
// keep every subfield has nothing to select, so it is copied whole below
|
|
185
|
+
// — walking it with no configured subfields would select nothing and
|
|
186
|
+
// drop the field entirely.
|
|
187
|
+
if (fieldCfg.children) {
|
|
184
188
|
const sub = extractReportingTokenContent(
|
|
185
189
|
data.subarray(valStart, valEnd), fieldCfg.children || EMPTY_MAP
|
|
186
190
|
);
|
package/lib/noise.js
CHANGED
|
@@ -13,15 +13,37 @@ const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
|
|
|
13
13
|
const { dbg: _whaDbg } = require('./logger');
|
|
14
14
|
const { parsePhone, getCountryMeta } = require('./Registration');
|
|
15
15
|
|
|
16
|
-
// ───
|
|
16
|
+
// ─── Server certificate validator ────────
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// ServerHello.payload carries the certificate that authenticates the static
|
|
19
|
+
// Noise key the server presented in this handshake. Two shapes travel on the
|
|
20
|
+
// wire, and they do not share a Details layout:
|
|
21
|
+
//
|
|
22
|
+
// flat NoiseCertificate — what a native mobile server returns
|
|
23
|
+
// field1 details, field2 signature (64 bytes, by the root CA)
|
|
24
|
+
// Details{ 1 serial, 2 issuer, 3 expires, 4 subject, 5 key }
|
|
25
|
+
//
|
|
26
|
+
// CertChain — what a companion server returns
|
|
27
|
+
// field1 leaf, field2 intermediate, each { 1 details, 2 signature }
|
|
28
|
+
// Details{ 1 serial, 2 issuerSerial, 3 key, 4 notBefore, 5 notAfter }
|
|
29
|
+
// the intermediate is signed by the root CA, the leaf by the intermediate
|
|
30
|
+
//
|
|
31
|
+
// Whichever shape arrives, two things decide whether the server is genuine:
|
|
32
|
+
// the signature must chain back to the root CA below, and the certified key
|
|
33
|
+
// must equal the static key from this handshake. That second check is the one
|
|
34
|
+
// that binds the certificate to this connection — a signature alone only
|
|
35
|
+
// proves the certificate is real, not that it belongs to whoever is talking.
|
|
36
|
+
// Neither check may be skipped, so every path out of here either validates or
|
|
37
|
+
// throws; there is no quiet return.
|
|
22
38
|
//
|
|
23
39
|
// All decoded manually to avoid a protobufjs dependency in noise.js.
|
|
24
40
|
|
|
41
|
+
// The root of trust. A certificate that does not chain back to this key did
|
|
42
|
+
// not come from WhatsApp.
|
|
43
|
+
const NOISE_ROOT_CA_PUBLIC_KEY = Buffer.from(
|
|
44
|
+
'142375574d0a587166aae71ebe516437c4a28b73e3695c6ce1f7f9545da8ee6b', 'hex'
|
|
45
|
+
);
|
|
46
|
+
|
|
25
47
|
function _pbReadVarint(buf, off) {
|
|
26
48
|
let v = 0, shift = 0;
|
|
27
49
|
while (off.pos < buf.length) {
|
|
@@ -46,6 +68,10 @@ function _pbReadFields(buf) {
|
|
|
46
68
|
const len = _pbReadVarint(buf, off);
|
|
47
69
|
fields[fn] = buf.slice(off.pos, off.pos + len);
|
|
48
70
|
off.pos += len;
|
|
71
|
+
} else if (wt === 5) {
|
|
72
|
+
off.pos += 4; // no message here uses fixed32/fixed64, but stepping over
|
|
73
|
+
} else if (wt === 1) {
|
|
74
|
+
off.pos += 8; // one costs nothing and keeps later fields readable
|
|
49
75
|
} else {
|
|
50
76
|
break; // unexpected wire type — stop parsing
|
|
51
77
|
}
|
|
@@ -53,19 +79,97 @@ function _pbReadFields(buf) {
|
|
|
53
79
|
return fields;
|
|
54
80
|
}
|
|
55
81
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
function _certFail(what) {
|
|
83
|
+
throw new Error('WA server cert validation failed: ' + what);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _certBytes(value, what) {
|
|
87
|
+
if (!Buffer.isBuffer(value) || value.length === 0) _certFail('missing ' + what);
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A public key travels either bare or behind the 0x05 Signal type byte. Accept
|
|
92
|
+
// both spellings so an encoding difference never reads as a forged key.
|
|
93
|
+
function _certKeyBytes(value, what) {
|
|
94
|
+
const key = _certBytes(value, what);
|
|
95
|
+
const bare = (key.length === 33 && key[0] === 0x05) ? key.slice(1) : key;
|
|
96
|
+
if (bare.length !== 32) _certFail(what + ' is ' + bare.length + ' bytes, expected 32');
|
|
97
|
+
return bare;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _certVerifySignature(signerKey, details, signature, what) {
|
|
101
|
+
const sig = _certBytes(signature, what + ' signature');
|
|
102
|
+
if (sig.length !== 64) {
|
|
103
|
+
_certFail(what + ' signature is ' + sig.length + ' bytes, expected 64');
|
|
104
|
+
}
|
|
105
|
+
if (!curveJs.verify(signerKey, details, sig)) {
|
|
106
|
+
_certFail(what + ' has an invalid signature');
|
|
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
|
-
//
|
|
176
|
-
//
|
|
279
|
+
// A frame is a 3-byte big-endian length followed by that many bytes, and the
|
|
280
|
+
// wire is symmetric: what we write is shaped exactly like what we read below.
|
|
281
|
+
// The only thing that travels outside this framing is the handshake prologue,
|
|
282
|
+
// which the first frame is concatenated onto rather than wrapped in.
|
|
283
|
+
const FRAME_HEADER_SIZE = 3;
|
|
284
|
+
const MAX_FRAME_LENGTH = 0xffffff; // what a 3-byte length can express
|
|
285
|
+
|
|
177
286
|
function makeFrame(payload) {
|
|
178
|
-
const len
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
header
|
|
184
|
-
header[
|
|
185
|
-
|
|
186
|
-
header[
|
|
187
|
-
header[5] = lss & 0xff;
|
|
287
|
+
const len = payload.length;
|
|
288
|
+
if (len > MAX_FRAME_LENGTH) {
|
|
289
|
+
// Silently truncating the length here would desync the stream for good.
|
|
290
|
+
throw new Error('Frame length ' + len + ' exceeds ' + MAX_FRAME_LENGTH);
|
|
291
|
+
}
|
|
292
|
+
const header = Buffer.alloc(FRAME_HEADER_SIZE);
|
|
293
|
+
header[0] = (len >>> 16) & 0xff;
|
|
294
|
+
header[1] = (len >>> 8) & 0xff;
|
|
295
|
+
header[2] = len & 0xff;
|
|
188
296
|
return Buffer.concat([header, payload]);
|
|
189
297
|
}
|
|
190
298
|
|
|
191
|
-
// messageLengthBuffer: always 3 bytes (server → client).
|
|
192
299
|
function readFrameLength(data) {
|
|
193
|
-
if (data.length <
|
|
300
|
+
if (data.length < FRAME_HEADER_SIZE) return -1;
|
|
194
301
|
return (data[0] << 16) | (data[1] << 8) | data[2];
|
|
195
302
|
}
|
|
196
303
|
|
|
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 <
|
|
439
|
+
if (this.rxBuf.length < FRAME_HEADER_SIZE) break;
|
|
305
440
|
const frameLen = readFrameLength(this.rxBuf);
|
|
306
|
-
if (this.rxBuf.length <
|
|
307
|
-
const frame = this.rxBuf.slice(
|
|
308
|
-
this.rxBuf = this.rxBuf.slice(
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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) {
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -90,7 +90,10 @@ class SenderKeyStore {
|
|
|
90
90
|
constructor() {
|
|
91
91
|
this._data = {};
|
|
92
92
|
this._filePath = null;
|
|
93
|
+
// _rev counts mutations so an async write that started before a mutation
|
|
94
|
+
// landed cannot clear _dirty on its behalf.
|
|
93
95
|
this._dirty = false;
|
|
96
|
+
this._rev = 0;
|
|
94
97
|
this._saveTimer = null;
|
|
95
98
|
|
|
96
99
|
// Detachable, for the same reason as in SignalStore: a replaced store must
|
|
@@ -159,13 +162,22 @@ class SenderKeyStore {
|
|
|
159
162
|
|
|
160
163
|
_save() {
|
|
161
164
|
this._dirty = true;
|
|
165
|
+
this._rev++;
|
|
162
166
|
if (!this._filePath) return;
|
|
163
167
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
164
168
|
this._saveTimer = setTimeout(() => {
|
|
165
169
|
this._saveTimer = null;
|
|
170
|
+
// The revision this write is a snapshot of.
|
|
171
|
+
const rev = this._rev;
|
|
166
172
|
const data = JSON.stringify(this._data, null, 2);
|
|
167
173
|
fs.mkdirSync(path.dirname(this._filePath), { recursive: true });
|
|
168
|
-
|
|
174
|
+
// Only clear the flag when nothing changed while the write was running.
|
|
175
|
+
// A mutation that arrived after the snapshot is not in `data`, and its
|
|
176
|
+
// own debounce timer may never fire — clearing here would make the
|
|
177
|
+
// exit-time _flushSync() skip it and lose it.
|
|
178
|
+
fs.writeFile(this._filePath, data, 'utf8', () => {
|
|
179
|
+
if (this._rev === rev) this._dirty = false;
|
|
180
|
+
});
|
|
169
181
|
}, _SK_DEBOUNCE_MS);
|
|
170
182
|
}
|
|
171
183
|
|
|
@@ -32,8 +32,10 @@ class SignalStore {
|
|
|
32
32
|
// a freshly serialised object) invalidates the entry by identity.
|
|
33
33
|
this._openSessionMemo = new Map();
|
|
34
34
|
|
|
35
|
-
// Debounce state
|
|
35
|
+
// Debounce state. _rev counts mutations so an async write that started
|
|
36
|
+
// before a mutation landed cannot clear _dirty on its behalf.
|
|
36
37
|
this._dirty = false;
|
|
38
|
+
this._rev = 0;
|
|
37
39
|
this._saveTimer = null;
|
|
38
40
|
|
|
39
41
|
// Flush synchronously on exit so no state is ever lost.
|
|
@@ -121,10 +123,13 @@ class SignalStore {
|
|
|
121
123
|
// Called on every in-memory mutation — replaces the old synchronous _save().
|
|
122
124
|
_save() {
|
|
123
125
|
this._dirty = true;
|
|
126
|
+
this._rev++;
|
|
124
127
|
if (!this._filePath) return;
|
|
125
128
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
126
129
|
this._saveTimer = setTimeout(() => {
|
|
127
130
|
this._saveTimer = null;
|
|
131
|
+
// The revision this write is a snapshot of.
|
|
132
|
+
const rev = this._rev;
|
|
128
133
|
const data = JSON.stringify({
|
|
129
134
|
sessions: this._sessions,
|
|
130
135
|
preKeys: this._preKeys,
|
|
@@ -134,7 +139,13 @@ class SignalStore {
|
|
|
134
139
|
});
|
|
135
140
|
const dir = path.dirname(this._filePath);
|
|
136
141
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
137
|
-
|
|
142
|
+
// Only clear the flag when nothing changed while the write was running.
|
|
143
|
+
// A mutation that arrived after the snapshot is not in `data`, and its
|
|
144
|
+
// own debounce timer may never fire — clearing here would make the
|
|
145
|
+
// exit-time _flushSync() skip it and lose it.
|
|
146
|
+
fs.writeFile(this._filePath, data, 'utf8', () => {
|
|
147
|
+
if (this._rev === rev) this._dirty = false;
|
|
148
|
+
});
|
|
138
149
|
}, DEBOUNCE_MS);
|
|
139
150
|
}
|
|
140
151
|
|
|
@@ -129,7 +129,7 @@ exports.calculateSignature = function(privKey, message) {
|
|
|
129
129
|
return Buffer.from(curveJs.sign(privKey, message));
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
exports.verifySignature = function(pubKey, msg, sig
|
|
132
|
+
exports.verifySignature = function(pubKey, msg, sig) {
|
|
133
133
|
pubKey = scrubPubKeyFormat(pubKey);
|
|
134
134
|
if (!pubKey || pubKey.byteLength != 32) {
|
|
135
135
|
throw new Error("Invalid public key");
|
|
@@ -140,5 +140,5 @@ exports.verifySignature = function(pubKey, msg, sig, isInit) {
|
|
|
140
140
|
if (!sig || sig.byteLength != 64) {
|
|
141
141
|
throw new Error("Invalid signature");
|
|
142
142
|
}
|
|
143
|
-
return
|
|
143
|
+
return curveJs.verify(pubKey, msg, sig);
|
|
144
144
|
};
|
|
@@ -4,8 +4,20 @@ const crypto = require('./crypto.js');
|
|
|
4
4
|
var VERSION = 0;
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
// Inputs arrive as ArrayBuffers (createFor requires them) and as strings for
|
|
8
|
+
// the identifiers, neither of which Buffer.concat or crypto.hash accept.
|
|
9
|
+
function toBuffer(value) {
|
|
10
|
+
if (Buffer.isBuffer(value)) return value;
|
|
11
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
12
|
+
if (ArrayBuffer.isView(value)) {
|
|
13
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
14
|
+
}
|
|
15
|
+
return Buffer.from(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
7
19
|
async function iterateHash(data, key, count) {
|
|
8
|
-
const combined =
|
|
20
|
+
const combined = Buffer.concat([toBuffer(data), toBuffer(key)]);
|
|
9
21
|
const result = crypto.hash(combined);
|
|
10
22
|
if (--count === 0) {
|
|
11
23
|
return result;
|
|
@@ -34,12 +46,11 @@ function getEncodedChunk(hash, offset) {
|
|
|
34
46
|
|
|
35
47
|
async function getDisplayStringFor(identifier, key, iterations) {
|
|
36
48
|
const bytes = Buffer.concat([
|
|
37
|
-
shortToArrayBuffer(VERSION),
|
|
38
|
-
key,
|
|
39
|
-
identifier
|
|
49
|
+
toBuffer(shortToArrayBuffer(VERSION)),
|
|
50
|
+
toBuffer(key),
|
|
51
|
+
toBuffer(identifier)
|
|
40
52
|
]);
|
|
41
|
-
const
|
|
42
|
-
const output = new Uint8Array(await iterateHash(arraybuf, key, iterations));
|
|
53
|
+
const output = new Uint8Array(await iterateHash(bytes, key, iterations));
|
|
43
54
|
return getEncodedChunk(output, 0) +
|
|
44
55
|
getEncodedChunk(output, 5) +
|
|
45
56
|
getEncodedChunk(output, 10) +
|
|
@@ -25,8 +25,13 @@ class SessionBuilder {
|
|
|
25
25
|
if (!await this.storage.isTrustedIdentity(this.addr.id, device.identityKey)) {
|
|
26
26
|
throw new errors.UntrustedIdentityKeyError(this.addr.id, device.identityKey);
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// The signed pre-key is what ties this bundle to the identity key we
|
|
29
|
+
// just decided to trust. Taking it unverified would let anyone who
|
|
30
|
+
// can answer for this address hand us a key of their choosing.
|
|
31
|
+
if (!curve.verifySignature(device.identityKey, device.signedPreKey.publicKey,
|
|
32
|
+
device.signedPreKey.signature)) {
|
|
33
|
+
throw new errors.PreKeyError('Signed pre-key signature does not verify against the identity key');
|
|
34
|
+
}
|
|
30
35
|
const baseKey = curve.generateKeyPair();
|
|
31
36
|
const devicePreKey = device.preKey && device.preKey.publicKey;
|
|
32
37
|
const session = await this.initSession(true, baseKey, undefined, device.identityKey,
|