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.
- package/README.md +532 -143
- package/cli.js +206 -55
- package/index.js +13 -1
- package/lib/Client.js +920 -172
- package/lib/GroupParticipant.js +141 -0
- package/lib/MediaService.js +28 -1
- package/lib/appstate/AppStateStore.js +100 -0
- package/lib/appstate/AppStateSync.js +292 -0
- package/lib/appstate/LTHash.js +119 -0
- package/lib/appstate/Mutations.js +106 -0
- package/lib/appstate/SyncdProto.js +263 -0
- package/lib/messages/MessageSender.js +32 -0
- package/lib/messages/TcTokenStore.js +44 -0
- package/lib/proto/MessageProto.js +55 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/lib/MediaService.js
CHANGED
|
@@ -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 };
|