whalibmob 5.7.2 → 5.9.0

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.
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ // Participant results for group operations.
4
+ //
5
+ // A group action is not all-or-nothing. When four people are added to a group,
6
+ // the server answers with four <participant> nodes and decides each one on its
7
+ // own merits: three go in, the fourth comes back carrying error="403" because
8
+ // their privacy settings do not let strangers add them. Reporting only the JIDs
9
+ // that worked throws that away — the caller sees three where it asked for four
10
+ // and has no idea which one failed, let alone why.
11
+ //
12
+ // So every participant the server answered about is returned, with its status
13
+ // attached. The objects stringify to the bare JID, so anything that used to
14
+ // print or join the plain string list keeps working untouched.
15
+
16
+ const OK_STATUS = '200';
17
+
18
+ function _toInt(v) {
19
+ if (v == null) return null;
20
+ const n = parseInt(String(v), 10);
21
+ return Number.isFinite(n) ? n : null;
22
+ }
23
+
24
+ function _child(node, desc) {
25
+ if (!node || !Array.isArray(node.content)) return null;
26
+ return node.content.find(c => c && c.description === desc) || null;
27
+ }
28
+
29
+ class GroupParticipantResult {
30
+ constructor(fields) {
31
+ /** @type {string} bare JID the server answered about */
32
+ this.jid = fields.jid;
33
+ /** @type {string} '200' when the action went through, else the error code */
34
+ this.status = fields.status;
35
+ /** @type {number|null} the error code as a number, null on success */
36
+ this.error = fields.error;
37
+ /** @type {'admin'|'superadmin'|null} */
38
+ this.admin = fields.admin || null;
39
+ /** @type {string|null} phone JID — set for both address families */
40
+ this.phoneNumber = fields.phoneNumber || null;
41
+ /** @type {string|null} LID — set when the server told us one */
42
+ this.lid = fields.lid || null;
43
+ /** @type {string|null} */
44
+ this.displayName = fields.displayName || null;
45
+ // Present when an add was refused because the person cannot be put into a
46
+ // group directly. The code inside is what a group invite message is built
47
+ // from, and it stops working at `expiration`.
48
+ /** @type {{code: string, expiration: number}|null} */
49
+ this.addRequest = fields.addRequest || null;
50
+ // The raw node, for anything the server sends that is not modelled here.
51
+ Object.defineProperty(this, 'node', {
52
+ value: fields.node || null, enumerable: false, writable: false
53
+ });
54
+ }
55
+
56
+ /** Whether the server carried the action out for this participant. */
57
+ get ok() { return this.error == null; }
58
+
59
+ /** Whether the refusal is the kind an invite message can work around. */
60
+ get needsInvite() { return !!(this.addRequest && this.addRequest.code); }
61
+
62
+ toString() { return this.jid; }
63
+
64
+ toJSON() {
65
+ return {
66
+ jid: this.jid, status: this.status, error: this.error, admin: this.admin,
67
+ phoneNumber: this.phoneNumber, lid: this.lid,
68
+ displayName: this.displayName, addRequest: this.addRequest
69
+ };
70
+ }
71
+ }
72
+
73
+ // One <participant jid=… [error=…] [type=…] [phone_number=…] [lid=…]> node,
74
+ // optionally wrapping an <add_request code=… expiration=…/>.
75
+ function parseParticipantNode(node) {
76
+ const a = (node && node.attrs) || {};
77
+ const rawJid = a.jid != null ? a.jid : a.value;
78
+ if (rawJid == null) return null;
79
+ const jid = String(rawJid);
80
+
81
+ const error = _toInt(a.error) || null; // error="0" means the same as absent
82
+ const type = a.type ? String(a.type) : null;
83
+ const isLid = jid.endsWith('@lid');
84
+
85
+ let addRequest = null;
86
+ const reqNode = _child(node, 'add_request');
87
+ if (reqNode && reqNode.attrs && reqNode.attrs.code) {
88
+ addRequest = {
89
+ code: String(reqNode.attrs.code),
90
+ expiration: _toInt(reqNode.attrs.expiration) || 0
91
+ };
92
+ }
93
+
94
+ return new GroupParticipantResult({
95
+ jid,
96
+ status: error != null ? String(error) : OK_STATUS,
97
+ error,
98
+ admin: (type === 'admin' || type === 'superadmin') ? type : null,
99
+ phoneNumber: isLid ? (a.phone_number ? String(a.phone_number) : null) : jid,
100
+ lid: isLid ? jid : (a.lid ? String(a.lid) : null),
101
+ displayName: a.display_name ? String(a.display_name) : null,
102
+ addRequest,
103
+ node
104
+ });
105
+ }
106
+
107
+ // Every <participant> under the given node, parsed. Failures included — that is
108
+ // the whole point.
109
+ function parseParticipantNodes(parent) {
110
+ if (!parent || !Array.isArray(parent.content)) return [];
111
+ return parent.content
112
+ .filter(n => n && n.description === 'participant')
113
+ .map(parseParticipantNode)
114
+ .filter(Boolean);
115
+ }
116
+
117
+ // Somebody waiting for an admin to let them into a group.
118
+ class GroupJoinRequest {
119
+ constructor(jid, requestedAt) {
120
+ this.jid = jid;
121
+ /** @type {number} unix seconds, 0 when the server did not say */
122
+ this.requestedAt = requestedAt || 0;
123
+ }
124
+ toString() { return this.jid; }
125
+ toJSON() { return { jid: this.jid, requestedAt: this.requestedAt }; }
126
+ }
127
+
128
+ function parseJoinRequestNode(node) {
129
+ const a = (node && node.attrs) || {};
130
+ const rawJid = a.jid != null ? a.jid : a.user;
131
+ if (rawJid == null) return null;
132
+ return new GroupJoinRequest(String(rawJid), _toInt(a.request_time) || 0);
133
+ }
134
+
135
+ module.exports = {
136
+ GroupParticipantResult,
137
+ GroupJoinRequest,
138
+ parseParticipantNode,
139
+ parseParticipantNodes,
140
+ parseJoinRequestNode
141
+ };
@@ -223,8 +223,34 @@ function _tryUpload(host, mediaPath, token, auth, encrypted, web) {
223
223
  });
224
224
  }
225
225
 
226
+ // opts.fileEncSha256, when given, is checked against the blob as it arrived.
227
+ // The MAC inside decryptMedia already proves the plaintext was not tampered
228
+ // with, but this catches a truncated or substituted download before spending
229
+ // the work on it, and tells the two failures apart.
230
+ // Where a message's media actually lives. Most carry an absolute `url`, but a
231
+ // message that arrived through history sync often has only the `directPath`,
232
+ // which is that URL with the CDN host taken off the front.
233
+ const CDN_HOST = 'mmg.whatsapp.net';
234
+
235
+ function resolveMediaUrl(url, directPath) {
236
+ if (url) return String(url);
237
+ if (!directPath) return null;
238
+ const p = String(directPath);
239
+ return 'https://' + CDN_HOST + (p.startsWith('/') ? '' : '/') + p;
240
+ }
241
+
226
242
  async function downloadMedia(url, mediaKey, keyName, opts) {
227
243
  const encrypted = await _httpGet(url, opts);
244
+ const expected = opts && opts.fileEncSha256;
245
+ if (expected && expected.length) {
246
+ const actual = crypto.createHash('sha256').update(encrypted).digest();
247
+ const want = Buffer.from(expected);
248
+ // timingSafeEqual throws on a length mismatch rather than returning false.
249
+ if (want.length !== actual.length || !crypto.timingSafeEqual(actual, want)) {
250
+ throw new Error('downloadMedia: the downloaded file does not match the message ' +
251
+ '(fileEncSha256 mismatch)');
252
+ }
253
+ }
228
254
  return decryptMedia(encrypted, Buffer.from(mediaKey), keyName);
229
255
  }
230
256
 
@@ -261,5 +287,6 @@ module.exports = {
261
287
  // message media, so they need a fetch that does not try to decrypt anything.
262
288
  httpGetBuffer: _httpGet,
263
289
  deriveMediaKeyData,
264
- getMediaKeyName
290
+ getMediaKeyName,
291
+ resolveMediaUrl
265
292
  };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { newState, HASH_BYTES } = require('./LTHash');
6
+
7
+ // The collections app state is divided into. Which one a change belongs to is
8
+ // not ours to choose — the server sorts patches by collection and a mutation
9
+ // filed under the wrong one is refused.
10
+ const COLLECTIONS = [
11
+ 'critical_block',
12
+ 'critical_unblock_low',
13
+ 'regular_high',
14
+ 'regular_low',
15
+ 'regular'
16
+ ];
17
+
18
+ /**
19
+ * Where each collection has got to.
20
+ *
21
+ * This has to survive a restart or every reconnect asks for a full snapshot of
22
+ * everything — slow, and it throws away the record of which index carried which
23
+ * value, without which a later mutation cannot displace an earlier one.
24
+ */
25
+ class AppStateStore {
26
+ constructor(filePath) {
27
+ this._path = filePath;
28
+ this._states = new Map(); // collection → { version, hash, indexValueMap }
29
+ this._load();
30
+ }
31
+
32
+ _load() {
33
+ try {
34
+ if (!fs.existsSync(this._path)) return;
35
+ const raw = JSON.parse(fs.readFileSync(this._path, 'utf8'));
36
+ for (const [name, s] of Object.entries(raw)) {
37
+ const hash = s.hash ? Buffer.from(s.hash, 'base64') : Buffer.alloc(HASH_BYTES);
38
+ const map = {};
39
+ for (const [k, v] of Object.entries(s.indexValueMap || {})) {
40
+ map[k] = { valueMac: Buffer.from(v, 'base64') };
41
+ }
42
+ this._states.set(name, {
43
+ // A hash of the wrong length is not something to carry forward; the
44
+ // arithmetic would be nonsense. Start that collection over instead.
45
+ version: hash.length === HASH_BYTES ? (Number(s.version) || 0) : 0,
46
+ hash: hash.length === HASH_BYTES ? hash : Buffer.alloc(HASH_BYTES),
47
+ indexValueMap: hash.length === HASH_BYTES ? map : {}
48
+ });
49
+ }
50
+ } catch (_) {}
51
+ }
52
+
53
+ _save() {
54
+ try {
55
+ const dir = path.dirname(this._path);
56
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
57
+ const obj = {};
58
+ for (const [name, s] of this._states) {
59
+ const map = {};
60
+ for (const [k, v] of Object.entries(s.indexValueMap || {})) {
61
+ map[k] = Buffer.from(v.valueMac).toString('base64');
62
+ }
63
+ obj[name] = {
64
+ version: s.version || 0,
65
+ hash: Buffer.from(s.hash).toString('base64'),
66
+ indexValueMap: map
67
+ };
68
+ }
69
+ fs.writeFileSync(this._path, JSON.stringify(obj), 'utf8');
70
+ } catch (_) {}
71
+ }
72
+
73
+ get(name) {
74
+ if (!this._states.has(name)) this._states.set(name, newState());
75
+ return this._states.get(name);
76
+ }
77
+
78
+ set(name, state) {
79
+ this._states.set(name, {
80
+ version: state.version || 0,
81
+ hash: Buffer.from(state.hash),
82
+ indexValueMap: state.indexValueMap || {}
83
+ });
84
+ this._save();
85
+ }
86
+
87
+ /** Forget a collection so the next sync asks for a snapshot of it. */
88
+ reset(name) {
89
+ this._states.set(name, newState());
90
+ this._save();
91
+ }
92
+
93
+ versions() {
94
+ const out = {};
95
+ for (const name of COLLECTIONS) out[name] = this.get(name).version;
96
+ return out;
97
+ }
98
+ }
99
+
100
+ module.exports = { AppStateStore, COLLECTIONS };
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { hkdf } = require('@noble/hashes/hkdf');
5
+ const { sha256 } = require('@noble/hashes/sha256');
6
+ const { makeGenerator, newState } = require('./LTHash');
7
+ const S = require('./SyncdProto');
8
+
9
+ // The five keys every app-state mutation is worked with, expanded from the one
10
+ // key the primary device shares over an encrypted message.
11
+ //
12
+ // indexKey HMACs the index, so the same index always MACs the same
13
+ // valueEncryptionKey AES-256-CBC over the mutation body
14
+ // valueMacKey authenticates that body
15
+ // snapshotMacKey authenticates the state as a whole at a version
16
+ // patchMacKey authenticates one patch against that state
17
+ const KEY_EXPANSION_INFO = 'WhatsApp Mutation Keys';
18
+ const KEY_EXPANSION_LEN = 160;
19
+
20
+ function mutationKeys(keyData) {
21
+ const ikm = Buffer.isBuffer(keyData) ? keyData : Buffer.from(keyData);
22
+ const e = Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0),
23
+ Buffer.from(KEY_EXPANSION_INFO, 'utf8'), KEY_EXPANSION_LEN));
24
+ return {
25
+ indexKey: e.subarray(0, 32),
26
+ valueEncryptionKey: e.subarray(32, 64),
27
+ valueMacKey: e.subarray(64, 96),
28
+ snapshotMacKey: e.subarray(96, 128),
29
+ patchMacKey: e.subarray(128, 160)
30
+ };
31
+ }
32
+
33
+ function hmac(data, key, alg) {
34
+ return crypto.createHmac(alg || 'sha256', key).update(data).digest();
35
+ }
36
+
37
+ // The value MAC covers the operation, the key it was encrypted under, the
38
+ // ciphertext, and the length of that prefix. Binding the operation in is what
39
+ // stops a SET being replayed as a REMOVE.
40
+ function valueMac(operation, encContent, keyId, key) {
41
+ const id = Buffer.isBuffer(keyId) ? keyId : Buffer.from(keyId, 'base64');
42
+ const prefix = Buffer.concat([Buffer.from([operation === S.REMOVE ? 0x02 : 0x01]), id]);
43
+ const last = Buffer.alloc(8);
44
+ last[7] = prefix.length;
45
+ return hmac(Buffer.concat([prefix, encContent, last]), key, 'sha512').subarray(0, 32);
46
+ }
47
+
48
+ // Version numbers go into the MACs as 8 bytes, big-endian.
49
+ function versionBytes(version) {
50
+ const b = Buffer.alloc(8);
51
+ b.writeUInt32BE(version >>> 0, 4);
52
+ return b;
53
+ }
54
+
55
+ function snapshotMac(hash, version, name, key) {
56
+ return hmac(Buffer.concat([hash, versionBytes(version), Buffer.from(name, 'utf8')]), key);
57
+ }
58
+
59
+ function patchMac(snapMac, valueMacs, version, name, key) {
60
+ return hmac(Buffer.concat([snapMac, ...valueMacs, versionBytes(version),
61
+ Buffer.from(name, 'utf8')]), key);
62
+ }
63
+
64
+ function aesDecrypt(buf, key) {
65
+ const iv = buf.subarray(0, 16);
66
+ const d = crypto.createDecipheriv('aes-256-cbc', key, iv);
67
+ return Buffer.concat([d.update(buf.subarray(16)), d.final()]);
68
+ }
69
+
70
+ function aesEncrypt(buf, key) {
71
+ const iv = crypto.randomBytes(16);
72
+ const c = crypto.createCipheriv('aes-256-cbc', key, iv);
73
+ return Buffer.concat([iv, c.update(buf), c.final()]);
74
+ }
75
+
76
+ class MissingAppStateKeyError extends Error {
77
+ constructor(keyId) {
78
+ super('app state key "' + keyId + '" has not been shared with this device yet');
79
+ this.name = 'MissingAppStateKeyError';
80
+ this.isMissingKey = true;
81
+ this.keyId = keyId;
82
+ }
83
+ }
84
+
85
+ // ─── decoding ────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Walk a run of mutations, decrypting each and folding it into the hash.
89
+ *
90
+ * getKey(base64KeyId) → { keyData } | null
91
+ * onMutation({ index, action, operation }) is called for every one that decodes.
92
+ *
93
+ * A record that fails its own MAC or its own decryption is skipped rather than
94
+ * abandoning the run: one corrupt row should not cost the whole collection. A
95
+ * missing key is different — nothing after it can be read either, and the fix
96
+ * is to wait for the key rather than to carry on — so that one is thrown.
97
+ */
98
+ function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
99
+ const gen = makeGenerator(state);
100
+ const cache = new Map();
101
+ let skipped = 0;
102
+
103
+ const keysFor = (keyId) => {
104
+ const b64 = Buffer.from(keyId).toString('base64');
105
+ if (cache.has(b64)) return cache.get(b64);
106
+ const rec = getKey(b64);
107
+ if (!rec || !rec.keyData) throw new MissingAppStateKeyError(b64);
108
+ const k = mutationKeys(rec.keyData);
109
+ cache.set(b64, k);
110
+ return k;
111
+ };
112
+
113
+ for (const m of mutations) {
114
+ if (!m || !m.valueBlob || !m.keyId) { skipped++; continue; }
115
+ const keys = keysFor(m.keyId);
116
+
117
+ const encContent = m.valueBlob.subarray(0, -32);
118
+ const theirMac = m.valueBlob.subarray(-32);
119
+
120
+ if (validateMacs) {
121
+ const ours = valueMac(m.operation, encContent, m.keyId, keys.valueMacKey);
122
+ if (!ours.equals(theirMac)) { skipped++; continue; }
123
+ }
124
+
125
+ let plain;
126
+ try { plain = aesDecrypt(encContent, keys.valueEncryptionKey); }
127
+ catch (_) { skipped++; continue; }
128
+
129
+ let data;
130
+ try { data = S.decodeSyncActionData(plain); }
131
+ catch (_) { skipped++; continue; }
132
+
133
+ if (validateMacs && m.indexBlob) {
134
+ const ours = hmac(data.index, keys.indexKey);
135
+ if (!ours.equals(m.indexBlob)) { skipped++; continue; }
136
+ }
137
+
138
+ let index;
139
+ try { index = JSON.parse(data.index.toString('utf8')); }
140
+ catch (_) { skipped++; continue; }
141
+
142
+ onMutation({ index, action: data.value, operation: m.operation });
143
+ gen.mix({
144
+ indexMac: m.indexBlob,
145
+ valueMac: theirMac,
146
+ remove: m.operation === S.REMOVE
147
+ });
148
+ }
149
+
150
+ return Object.assign(gen.finish(), { skipped });
151
+ }
152
+
153
+ /**
154
+ * Decode a snapshot — the whole collection, at one version, from nothing.
155
+ *
156
+ * A snapshot whose MAC does not check out is reported rather than thrown: the
157
+ * usual cause is a record we had to skip above, which leaves our hash short of
158
+ * the server's through no fault of the rest. The caller decides whether partial
159
+ * state is worth keeping.
160
+ */
161
+ function decodeSnapshot(name, snapshot, getKey, getMutation, validateMacs) {
162
+ const state = newState();
163
+ state.version = snapshot.version;
164
+
165
+ const res = decodeMutations(snapshot.records, state, getKey, getMutation, validateMacs);
166
+ state.hash = res.hash;
167
+ state.indexValueMap = res.indexValueMap;
168
+
169
+ let macOk = true;
170
+ if (validateMacs && snapshot.keyId) {
171
+ const b64 = Buffer.from(snapshot.keyId).toString('base64');
172
+ const rec = getKey(b64);
173
+ if (!rec || !rec.keyData) throw new MissingAppStateKeyError(b64);
174
+ const k = mutationKeys(rec.keyData);
175
+ macOk = snapshotMac(state.hash, state.version, name, k.snapshotMacKey)
176
+ .equals(snapshot.mac || Buffer.alloc(0));
177
+ }
178
+
179
+ return { state, macOk, skipped: res.skipped };
180
+ }
181
+
182
+ /**
183
+ * Apply a run of patches on top of a state we already hold.
184
+ *
185
+ * Stops at the first patch whose snapshot MAC does not match: from that point
186
+ * on our state and the server's have diverged, so everything after it would be
187
+ * applied to the wrong base. The caller's answer to that is to ask for a fresh
188
+ * snapshot.
189
+ */
190
+ function decodePatches(name, patches, initial, getKey, getMutation, validateMacs) {
191
+ const state = {
192
+ version: initial.version || 0,
193
+ hash: Buffer.from(initial.hash),
194
+ indexValueMap: Object.assign({}, initial.indexValueMap || {})
195
+ };
196
+
197
+ let macFailed = false;
198
+ let skipped = 0;
199
+
200
+ for (const p of patches) {
201
+ if (p.version != null) state.version = p.version;
202
+
203
+ if (validateMacs && p.keyId) {
204
+ const b64 = Buffer.from(p.keyId).toString('base64');
205
+ const rec = getKey(b64);
206
+ if (!rec || !rec.keyData) throw new MissingAppStateKeyError(b64);
207
+ const k = mutationKeys(rec.keyData);
208
+ const macs = p.mutations.map(m => m.valueBlob.subarray(-32));
209
+ if (!patchMac(p.snapshotMac, macs, state.version, name, k.patchMacKey)
210
+ .equals(p.patchMac || Buffer.alloc(0))) {
211
+ macFailed = true;
212
+ break;
213
+ }
214
+ }
215
+
216
+ const res = decodeMutations(p.mutations, state, getKey, getMutation, validateMacs);
217
+ state.hash = res.hash;
218
+ state.indexValueMap = res.indexValueMap;
219
+ skipped += res.skipped;
220
+
221
+ if (validateMacs && p.keyId) {
222
+ const k = mutationKeys(getKey(Buffer.from(p.keyId).toString('base64')).keyData);
223
+ if (!snapshotMac(state.hash, state.version, name, k.snapshotMacKey)
224
+ .equals(p.snapshotMac || Buffer.alloc(0))) {
225
+ macFailed = true;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+
231
+ return { state, macFailed, skipped };
232
+ }
233
+
234
+ // ─── encoding (the write path) ───────────────────────────────────────────────
235
+
236
+ /**
237
+ * Turn one change into a patch, and advance the state it applies to.
238
+ *
239
+ * The state has to move forward here rather than when the server answers: the
240
+ * patch is signed against the version it produces, so a second change made
241
+ * before the first is acknowledged still has to build on it.
242
+ */
243
+ function buildPatch({ name, index, action, apiVersion, operation }, keyId, keyData, state) {
244
+ const keys = mutationKeys(keyData);
245
+ const encKeyId = Buffer.isBuffer(keyId) ? keyId : Buffer.from(keyId, 'base64');
246
+ const indexBuf = Buffer.from(JSON.stringify(index), 'utf8');
247
+ const op = operation == null ? S.SET : operation;
248
+
249
+ const body = S.encodeSyncActionData({
250
+ index: indexBuf, value: action, apiVersion, timestampMs: Date.now()
251
+ });
252
+ const encValue = aesEncrypt(body, keys.valueEncryptionKey);
253
+ const vMac = valueMac(op, encValue, encKeyId, keys.valueMacKey);
254
+ const iMac = hmac(indexBuf, keys.indexKey);
255
+
256
+ const next = {
257
+ version: state.version || 0,
258
+ hash: Buffer.from(state.hash),
259
+ indexValueMap: Object.assign({}, state.indexValueMap || {})
260
+ };
261
+ const gen = makeGenerator(next);
262
+ gen.mix({ indexMac: iMac, valueMac: vMac, remove: op === S.REMOVE });
263
+ const mixed = gen.finish();
264
+ next.hash = mixed.hash;
265
+ next.indexValueMap = mixed.indexValueMap;
266
+ next.version += 1;
267
+
268
+ const sMac = snapshotMac(next.hash, next.version, name, keys.snapshotMacKey);
269
+
270
+ const patch = S.encodePatch({
271
+ version: next.version,
272
+ snapshotMac: sMac,
273
+ patchMac: patchMac(sMac, [vMac], next.version, name, keys.patchMacKey),
274
+ keyId: encKeyId,
275
+ mutations: [{
276
+ operation: op,
277
+ indexMac: iMac,
278
+ valueBlob: Buffer.concat([encValue, vMac]),
279
+ keyId: encKeyId
280
+ }]
281
+ });
282
+
283
+ return { patch, state: next };
284
+ }
285
+
286
+ module.exports = {
287
+ mutationKeys, valueMac, snapshotMac, patchMac, versionBytes,
288
+ aesEncrypt, aesDecrypt,
289
+ decodeMutations, decodeSnapshot, decodePatches, buildPatch,
290
+ MissingAppStateKeyError,
291
+ KEY_EXPANSION_INFO
292
+ };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const { hkdf } = require('@noble/hashes/hkdf');
4
+ const { sha256 } = require('@noble/hashes/sha256');
5
+
6
+ // A summation hash that survives being edited out of order.
7
+ //
8
+ // App state is a stream of mutations that the server hands out in pieces, and a
9
+ // client has to be able to prove the state it ended up with is the state the
10
+ // server meant it to have — without replaying every mutation ever made. An
11
+ // ordinary hash cannot do that: it only tells you about a sequence you fed it
12
+ // front to back.
13
+ //
14
+ // This one can, because it is addition. Every value is expanded into 128 bytes
15
+ // and folded into the running hash as 64 little-endian 16-bit words, each added
16
+ // with wraparound. Addition commutes, so the order the mutations arrive in does
17
+ // not matter, and it inverts, so a mutation that is replaced can be subtracted
18
+ // back out without touching the rest.
19
+ //
20
+ // The salt is the domain separator; it is what makes these hashes mean
21
+ // "WhatsApp app state" and not something else expanded from the same bytes.
22
+ const HASH_BYTES = 128;
23
+ const PATCH_SALT = 'WhatsApp Patch Integrity';
24
+
25
+ class LTHash {
26
+ constructor(salt) {
27
+ this.salt = salt;
28
+ }
29
+
30
+ // 128 bytes of key material for one value.
31
+ _expand(value) {
32
+ const ikm = Buffer.isBuffer(value) ? value : Buffer.from(value);
33
+ return Buffer.from(
34
+ hkdf(sha256, ikm, Buffer.alloc(0), Buffer.from(this.salt, 'utf8'), HASH_BYTES)
35
+ );
36
+ }
37
+
38
+ // Word-by-word, 16 bits at a time, little-endian, wrapping at 2^16.
39
+ //
40
+ // The wraparound is not a defect to guard against — it is what keeps the
41
+ // hash a fixed 128 bytes no matter how many mutations are folded in, and
42
+ // what makes subtraction the exact inverse of addition.
43
+ _pointwise(base, other, add) {
44
+ const a = Buffer.isBuffer(base) ? base : Buffer.from(base);
45
+ const out = Buffer.alloc(HASH_BYTES);
46
+ for (let i = 0; i < HASH_BYTES; i += 2) {
47
+ const x = a.readUInt16LE(i);
48
+ const y = other.readUInt16LE(i);
49
+ out.writeUInt16LE((add ? (x + y) : (x - y)) & 0xffff, i);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ add(hash, values) {
55
+ let out = hash;
56
+ for (const v of values) out = this._pointwise(out, this._expand(v), true);
57
+ return out;
58
+ }
59
+
60
+ subtract(hash, values) {
61
+ let out = hash;
62
+ for (const v of values) out = this._pointwise(out, this._expand(v), false);
63
+ return out;
64
+ }
65
+
66
+ // Retire the old values first, then fold the new ones in. Doing it the other
67
+ // way round is arithmetically the same, but this order matches how a patch
68
+ // reads: what it replaces, then what it puts there.
69
+ subtractThenAdd(hash, toAdd, toSubtract) {
70
+ return this.add(this.subtract(hash, toSubtract || []), toAdd || []);
71
+ }
72
+ }
73
+
74
+ const PATCH_INTEGRITY = new LTHash(PATCH_SALT);
75
+
76
+ // Folds a run of mutations into a state, keeping track of which index each
77
+ // value is filed under so a later mutation can displace it.
78
+ //
79
+ // indexValueMap is index MAC (base64) → { valueMac }. It is the reason a
80
+ // mutation can be replaced at all: without knowing the value an index used to
81
+ // carry, there is nothing to subtract back out.
82
+ function makeGenerator(state) {
83
+ const indexValueMap = Object.assign({}, state.indexValueMap || {});
84
+ const hash = Buffer.isBuffer(state.hash) ? state.hash : Buffer.alloc(HASH_BYTES);
85
+ const addBufs = [];
86
+ const subBufs = [];
87
+
88
+ return {
89
+ mix({ indexMac, valueMac, remove }) {
90
+ const key = Buffer.from(indexMac).toString('base64');
91
+ const prev = indexValueMap[key];
92
+ if (remove) {
93
+ // A remove for something we never had is the server telling us about a
94
+ // mutation we missed. There is nothing to subtract; the hash will not
95
+ // match at the end, and that mismatch is the signal to resync from a
96
+ // snapshot rather than something to paper over here.
97
+ if (!prev) return;
98
+ delete indexValueMap[key];
99
+ } else {
100
+ addBufs.push(Buffer.from(valueMac));
101
+ indexValueMap[key] = { valueMac: Buffer.from(valueMac) };
102
+ }
103
+ if (prev) subBufs.push(Buffer.from(prev.valueMac));
104
+ },
105
+
106
+ finish() {
107
+ return {
108
+ hash: PATCH_INTEGRITY.subtractThenAdd(hash, addBufs, subBufs),
109
+ indexValueMap
110
+ };
111
+ }
112
+ };
113
+ }
114
+
115
+ function newState() {
116
+ return { version: 0, hash: Buffer.alloc(HASH_BYTES), indexValueMap: {} };
117
+ }
118
+
119
+ module.exports = { LTHash, PATCH_INTEGRITY, makeGenerator, newState, HASH_BYTES };