whalibmob 5.24.2 → 5.26.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 +8 -0
- package/index.d.ts +12 -1
- package/lib/Client.js +207 -11
- package/lib/WebStore.js +4 -1
- package/lib/appstate/AppStateSync.js +15 -4
- package/lib/appstate/LTHash.js +10 -2
- package/lib/appstate/Mutations.js +2 -0
- package/lib/appstate/SyncdProto.js +6 -1
- package/lib/messages/MessageSender.js +15 -0
- package/lib/proto/MessageProto.js +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3575,6 +3575,14 @@ whalibmob implements the full lifecycle to prevent this:
|
|
|
3575
3575
|
|
|
3576
3576
|
Token storage uses the **LID JID** of the contact (e.g. `112345678901234@lid`) as the key — never the phone-number JID — matching WhatsApp's internal convention.
|
|
3577
3577
|
|
|
3578
|
+
**cstoken — the companion fallback.** When there is no `<tctoken>` for a recipient, the genuine client attaches a `<cstoken>` instead. Unlike a tctoken it is not granted per contact; it is computed locally:
|
|
3579
|
+
|
|
3580
|
+
```
|
|
3581
|
+
cstoken = HMAC-SHA256(nctSalt, recipient-LID)
|
|
3582
|
+
```
|
|
3583
|
+
|
|
3584
|
+
The `nctSalt` is an account-wide secret the server distributes over app state, so only a **companion (QR / pairing-code) session** ever holds one — a primary (SMS) session has none and sends no cstoken, exactly as the real primary does until it has minted its own. When a companion session receives the salt it fires a `nct_salt` event and persists it; from then on, any first DM without a tctoken carries a `<cstoken>` derived for that recipient's LID. Like the rest of this section it is fully automatic — there is nothing to call.
|
|
3585
|
+
|
|
3578
3586
|
<a id="account-restriction"></a>
|
|
3579
3587
|
|
|
3580
3588
|
### When 463 Means the Account Is Restricted
|
package/index.d.ts
CHANGED
|
@@ -665,7 +665,7 @@ export interface WhalibmobEvents {
|
|
|
665
665
|
privacy_settings: (p: { changes: any; settings: PrivacySettings }) => void;
|
|
666
666
|
|
|
667
667
|
app_state_sync: (r: AppStateSyncResult) => void;
|
|
668
|
-
app_state_mutation: (m: { collection: string; index: any; action: any; removed: boolean }) => void;
|
|
668
|
+
app_state_mutation: (m: { collection: string; index: any; action: any; removed: boolean; version?: number }) => void;
|
|
669
669
|
app_state_key_missing: (m: { collection: string; keyId: string }) => void;
|
|
670
670
|
app_state_keys: (m: { keys: any[] }) => void;
|
|
671
671
|
|
|
@@ -688,6 +688,8 @@ export interface WhalibmobEvents {
|
|
|
688
688
|
/** The server refused the client itself — `405` means the announced version is not accepted. */
|
|
689
689
|
client_rejected: (r: { reason: string; location?: string; message?: string }) => void;
|
|
690
690
|
version_update: (v: { from: string; to: string; source: string }) => void;
|
|
691
|
+
/** A companion session received the NCT salt (over app state) that cstoken is derived from. */
|
|
692
|
+
nct_salt: (s: { bytes: number }) => void;
|
|
691
693
|
apk_material_stale: (m: { materialVersion: string; liveVersion: string; hint?: string }) => void;
|
|
692
694
|
number_corrected: (n: { from: string; to: string }) => void;
|
|
693
695
|
|
|
@@ -819,6 +821,15 @@ export declare class WhalibmobClient extends EventEmitter {
|
|
|
819
821
|
syncAppState(names?: string[] | null, opts?: { snapshot?: boolean }): Promise<AppStateSyncResult>;
|
|
820
822
|
/** Whether an app-state key is available — `false` on an SMS session with no companion. */
|
|
821
823
|
canSyncAppState(): boolean;
|
|
824
|
+
/**
|
|
825
|
+
* Ask the primary device for app-state keys it never shared.
|
|
826
|
+
*
|
|
827
|
+
* Sent automatically when a sync runs into a key it does not have, so calling
|
|
828
|
+
* it is rarely necessary. Ids may be base64 or hex. The same id is not asked
|
|
829
|
+
* for twice within 24 hours; resolves to `null` when everything passed was
|
|
830
|
+
* held down, or when there is no connection to send it over.
|
|
831
|
+
*/
|
|
832
|
+
requestAppStateKeys(keyIds: string[]): Promise<string | null>;
|
|
822
833
|
|
|
823
834
|
// ─── Account restriction ─────────────────────────────────────────────────
|
|
824
835
|
fetchReachoutTimelock(): Promise<ReachoutTimelockState>;
|
package/lib/Client.js
CHANGED
|
@@ -79,6 +79,17 @@ const MEX_QUERY_REACHOUT_TIMELOCK = '23983697327930364';
|
|
|
79
79
|
// server takes a longer one, answers <iq type="result"/> and keeps the old text.
|
|
80
80
|
const ABOUT_MAX_LENGTH = 139;
|
|
81
81
|
|
|
82
|
+
// How many times a single syncAppState call will go back for more. A busy
|
|
83
|
+
// account really does need several rounds, so this is high enough not to cut a
|
|
84
|
+
// genuine sync short; it exists only so a server that never stops saying
|
|
85
|
+
// has_more_patches cannot keep us here indefinitely.
|
|
86
|
+
const MAX_APP_STATE_ROUNDS = 30;
|
|
87
|
+
|
|
88
|
+
// How long to wait before asking the primary device for the same app-state key
|
|
89
|
+
// again. A key it has not shared is usually one it will not share; asking on
|
|
90
|
+
// every reconnect would be a message to your own phone every few seconds.
|
|
91
|
+
const APP_STATE_KEY_REQUEST_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
92
|
+
|
|
82
93
|
// How long the first message to a contact waits for its trusted-contact token.
|
|
83
94
|
// One round-trip to the server, no more: past this the message goes out without
|
|
84
95
|
// a token rather than sitting in the queue, and the issuance finishes in the
|
|
@@ -1999,11 +2010,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1999
2010
|
const nodes = wanted.map(name => {
|
|
2000
2011
|
const st = this._appState.get(name);
|
|
2001
2012
|
const wantSnapshot = !!opts.snapshot || !st.version;
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2013
|
+
// Asking for a snapshot means asking for the collection from nothing, so
|
|
2014
|
+
// there is no version to ask from. The attribute is left off entirely
|
|
2015
|
+
// rather than sent as 0 — that is what the official client puts on the
|
|
2016
|
+
// wire, and a version on a snapshot request is a contradiction.
|
|
2017
|
+
const attrs = { name, return_snapshot: wantSnapshot ? 'true' : 'false' };
|
|
2018
|
+
if (!wantSnapshot) attrs.version = String(st.version);
|
|
2019
|
+
return new BinaryNode('collection', attrs, null);
|
|
2007
2020
|
});
|
|
2008
2021
|
|
|
2009
2022
|
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
@@ -2043,6 +2056,79 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2043
2056
|
return out;
|
|
2044
2057
|
}
|
|
2045
2058
|
|
|
2059
|
+
/**
|
|
2060
|
+
* Ask the primary device for app-state keys it never shared.
|
|
2061
|
+
*
|
|
2062
|
+
* App-state mutations are encrypted under keys the phone hands out in an
|
|
2063
|
+
* encrypted message of its own. Miss that message — linked while the phone
|
|
2064
|
+
* was off, a session that had to be rebuilt — and every patch signed with
|
|
2065
|
+
* that key is unreadable, and stays unreadable: the key is not re-offered,
|
|
2066
|
+
* and nothing later in the collection can be decoded past it. The request
|
|
2067
|
+
* goes out as a peer message, the same channel a placeholder resend uses, so
|
|
2068
|
+
* the server routes it to your own devices rather than delivering it as a
|
|
2069
|
+
* chat message to yourself.
|
|
2070
|
+
*
|
|
2071
|
+
* Asking twice for the same key inside a day achieves nothing, so each id is
|
|
2072
|
+
* remembered and held down for that long. This is what whatsmeow does with
|
|
2073
|
+
* `requestAppStateKeys`, and for the same reason.
|
|
2074
|
+
*
|
|
2075
|
+
* @param {string[]} keyIds base64 (or hex) key ids, as the decoder reports
|
|
2076
|
+
* @returns {Promise<string|null>} the id of the message that carried it
|
|
2077
|
+
*/
|
|
2078
|
+
async requestAppStateKeys(keyIds) {
|
|
2079
|
+
if (!Array.isArray(keyIds) || !keyIds.length) return null;
|
|
2080
|
+
if (!this._sender || !this._connected) return null;
|
|
2081
|
+
const me = this._store && this._store.me && this._store.me.id;
|
|
2082
|
+
if (!me) return null;
|
|
2083
|
+
|
|
2084
|
+
this._appStateKeyRequests = this._appStateKeyRequests || new Map();
|
|
2085
|
+
const now = Date.now();
|
|
2086
|
+
const want = [];
|
|
2087
|
+
|
|
2088
|
+
for (const id of keyIds) {
|
|
2089
|
+
if (!id) continue;
|
|
2090
|
+
// The decoder reports base64; a caller with the stored spelling has hex.
|
|
2091
|
+
// Both name the same bytes, so normalise before deduplicating.
|
|
2092
|
+
let raw;
|
|
2093
|
+
try {
|
|
2094
|
+
raw = /^[0-9a-fA-F]+$/.test(id) && id.length % 2 === 0
|
|
2095
|
+
? Buffer.from(id, 'hex')
|
|
2096
|
+
: Buffer.from(id, 'base64');
|
|
2097
|
+
} catch (_) { continue; }
|
|
2098
|
+
if (!raw || !raw.length) continue;
|
|
2099
|
+
|
|
2100
|
+
const key = raw.toString('base64');
|
|
2101
|
+
const last = this._appStateKeyRequests.get(key);
|
|
2102
|
+
if (last && now - last < APP_STATE_KEY_REQUEST_INTERVAL_MS) continue;
|
|
2103
|
+
this._appStateKeyRequests.set(key, now);
|
|
2104
|
+
want.push(raw);
|
|
2105
|
+
}
|
|
2106
|
+
if (!want.length) return null;
|
|
2107
|
+
|
|
2108
|
+
const {
|
|
2109
|
+
encodeMessage, encodeProtocolMessage, encodeAppStateSyncKeyRequest,
|
|
2110
|
+
PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST
|
|
2111
|
+
} = require('./proto/MessageProto');
|
|
2112
|
+
|
|
2113
|
+
const payload = encodeProtocolMessage({
|
|
2114
|
+
type: PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST,
|
|
2115
|
+
appStateSyncKeyRequest: encodeAppStateSyncKeyRequest(want)
|
|
2116
|
+
});
|
|
2117
|
+
const msgBuf = encodeMessage('protocol', payload);
|
|
2118
|
+
const msgId = this._genMsgId();
|
|
2119
|
+
|
|
2120
|
+
_whaDbg('[DBG] APP_STATE_KEY_REQUEST ' +
|
|
2121
|
+
want.map(b => b.toString('hex')).join(', '));
|
|
2122
|
+
|
|
2123
|
+
await this._sender._sendMessage(String(me), msgId, msgBuf, 'text', {
|
|
2124
|
+
_protocol: true, // not a reach-out; keeps it off the tcToken gate
|
|
2125
|
+
category: 'peer',
|
|
2126
|
+
pushPriority: 'high_force',
|
|
2127
|
+
additionalNodes: [new BinaryNode('meta', { appdata: 'default' }, null)]
|
|
2128
|
+
});
|
|
2129
|
+
return msgId;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2046
2132
|
async _applyAppStateResponse(resp, opts) {
|
|
2047
2133
|
const syncNode = findChild(resp, 'sync');
|
|
2048
2134
|
const cols = (syncNode && Array.isArray(syncNode.content))
|
|
@@ -2089,11 +2175,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2089
2175
|
}
|
|
2090
2176
|
} catch (err) {
|
|
2091
2177
|
if (err && err.isMissingKey) {
|
|
2092
|
-
// Nothing can be read until the primary shares
|
|
2178
|
+
// Nothing in this collection can be read until the primary shares
|
|
2179
|
+
// that key, and it will not offer one unasked — so ask, and leave the
|
|
2093
2180
|
// collection where it is so the next sync picks up from here.
|
|
2094
2181
|
_whaDbg('[DBG] APP_STATE ' + name + ' waiting for key ' + err.keyId);
|
|
2095
2182
|
summary[name] = { waitingForKey: err.keyId };
|
|
2096
2183
|
this.emit('app_state_key_missing', { collection: name, keyId: err.keyId });
|
|
2184
|
+
this.requestAppStateKeys([err.keyId]).catch(() => {});
|
|
2097
2185
|
continue;
|
|
2098
2186
|
}
|
|
2099
2187
|
throw err;
|
|
@@ -2123,10 +2211,23 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2123
2211
|
if (col.attrs && col.attrs.has_more_patches === 'true') more.push(name);
|
|
2124
2212
|
}
|
|
2125
2213
|
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2214
|
+
// The server hands a long collection over in pieces and says so with
|
|
2215
|
+
// has_more_patches; a hash that stopped agreeing puts the collection back
|
|
2216
|
+
// here too, once, as a snapshot. Either way there is more to fetch, and
|
|
2217
|
+
// stopping after one more round would leave the collection half-read
|
|
2218
|
+
// without anything saying so. Keep going until the server stops asking —
|
|
2219
|
+
// with a ceiling, because a server that always says "more" would otherwise
|
|
2220
|
+
// spin here forever.
|
|
2221
|
+
if (more.length) {
|
|
2222
|
+
const rounds = (opts._round || 0) + 1;
|
|
2223
|
+
if (rounds > MAX_APP_STATE_ROUNDS) {
|
|
2224
|
+
_whaDbg('[DBG] APP_STATE giving up after ' + rounds + ' rounds: ' + more.join(', '));
|
|
2225
|
+
} else {
|
|
2226
|
+
const again = await this.syncAppState(more,
|
|
2227
|
+
Object.assign({}, opts, { _round: rounds, snapshot: false }));
|
|
2228
|
+
applied += again.applied;
|
|
2229
|
+
Object.assign(summary, again.collections);
|
|
2230
|
+
}
|
|
2130
2231
|
}
|
|
2131
2232
|
|
|
2132
2233
|
this.emit('app_state_sync', { collections: summary, applied });
|
|
@@ -2229,11 +2330,32 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2229
2330
|
case 'delete':
|
|
2230
2331
|
this.emit('chat_removed', { jid, kind: what.kind, remote: true });
|
|
2231
2332
|
break;
|
|
2333
|
+
case 'nct_salt_sync': {
|
|
2334
|
+
// The salt the cstoken fallback is derived from. The server
|
|
2335
|
+
// distributes it into app state; a SET carries the bytes, a REMOVE
|
|
2336
|
+
// withdraws it. Kept on the store so it survives a reconnect. Only a
|
|
2337
|
+
// companion ever gets one, which is why cstoken is a companion-only
|
|
2338
|
+
// thing — a primary has no salt on file and sends none, exactly as
|
|
2339
|
+
// the real primary does until it has minted its own.
|
|
2340
|
+
if (removed) {
|
|
2341
|
+
if (this._store) { this._store.nctSalt = null; this._persistStore(); }
|
|
2342
|
+
_whaDbg('[DBG] NCT_SALT cleared');
|
|
2343
|
+
} else {
|
|
2344
|
+
const salt = a.nctSaltSyncAction && a.nctSaltSyncAction.salt;
|
|
2345
|
+
if (salt && salt.length && this._store) {
|
|
2346
|
+
this._store.nctSalt = Buffer.from(salt).toString('base64');
|
|
2347
|
+
this._persistStore();
|
|
2348
|
+
_whaDbg('[DBG] NCT_SALT stored (' + salt.length + ' bytes)');
|
|
2349
|
+
this.emit('nct_salt', { bytes: salt.length });
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
break;
|
|
2353
|
+
}
|
|
2232
2354
|
default:
|
|
2233
2355
|
// Something the server tracks that this library does not model yet.
|
|
2234
2356
|
// Reported rather than dropped, so it is visible that it happened.
|
|
2235
2357
|
this.emit('app_state_mutation', {
|
|
2236
|
-
collection, index: m.index, action: a, removed
|
|
2358
|
+
collection, index: m.index, action: a, removed, version: m.version
|
|
2237
2359
|
});
|
|
2238
2360
|
break;
|
|
2239
2361
|
}
|
|
@@ -2707,6 +2829,67 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2707
2829
|
return lid ? lid + device + '@lid' : null;
|
|
2708
2830
|
}
|
|
2709
2831
|
|
|
2832
|
+
// ─── cstoken ────────────────────────────────────────────────────────────────
|
|
2833
|
+
//
|
|
2834
|
+
// The anti-abuse token the genuine client attaches to a message when it has no
|
|
2835
|
+
// trusted-contact token for the recipient. Unlike a tctoken it is not granted
|
|
2836
|
+
// by the server per contact — it is computed locally:
|
|
2837
|
+
//
|
|
2838
|
+
// cstoken = HMAC-SHA256(nctSalt, recipient LID)
|
|
2839
|
+
//
|
|
2840
|
+
// The salt is account-wide and arrives over app state, so only a companion
|
|
2841
|
+
// ever holds one. A primary (an SMS session) has no salt on file and sends no
|
|
2842
|
+
// cstoken, which is what the real primary does until it has minted its own.
|
|
2843
|
+
//
|
|
2844
|
+
// This mirrors whatsmeow's generateCsToken line for line: a regular user, not
|
|
2845
|
+
// the PSA account and not a bot; the recipient resolved to a LID; the HMAC
|
|
2846
|
+
// taken over that LID's bare `user@lid` string.
|
|
2847
|
+
|
|
2848
|
+
/** The stored NCT salt as bytes, or null when this session has none. */
|
|
2849
|
+
_nctSalt() {
|
|
2850
|
+
const b64 = this._store && this._store.nctSalt;
|
|
2851
|
+
if (!b64) return null;
|
|
2852
|
+
try { const b = Buffer.from(b64, 'base64'); return b.length ? b : null; }
|
|
2853
|
+
catch (_) { return null; }
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
/**
|
|
2857
|
+
* The recipient's bare LID (`user@lid`), or null when there is no mapping.
|
|
2858
|
+
*
|
|
2859
|
+
* A JID already on the LID server is used as-is; a phone JID is resolved
|
|
2860
|
+
* through the same PN→LID map every send uses. Device and agent parts are
|
|
2861
|
+
* dropped — the token is taken over the non-AD address, as whatsmeow does.
|
|
2862
|
+
*/
|
|
2863
|
+
_bareLidFor(jid) {
|
|
2864
|
+
if (!jid) return null;
|
|
2865
|
+
const str = String(jid);
|
|
2866
|
+
const user = str.split('@')[0].split(':')[0];
|
|
2867
|
+
if (!user) return null;
|
|
2868
|
+
if (str.endsWith('@lid')) return user + '@lid';
|
|
2869
|
+
const lid = this._pnToLid && this._pnToLid.get(user);
|
|
2870
|
+
return lid ? lid + '@lid' : null;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/**
|
|
2874
|
+
* The cstoken bytes for a recipient, or null when none should be sent.
|
|
2875
|
+
*
|
|
2876
|
+
* Returns null — so no `<cstoken>` is attached — unless a salt is on file,
|
|
2877
|
+
* the recipient is a regular user, and a LID resolves for them. Every one of
|
|
2878
|
+
* those is a reason the genuine client also sends nothing.
|
|
2879
|
+
*/
|
|
2880
|
+
_csTokenFor(jid) {
|
|
2881
|
+
const salt = this._nctSalt();
|
|
2882
|
+
if (!salt) return null;
|
|
2883
|
+
|
|
2884
|
+
const { isRegularTcTokenUser } = require('./messages/TcTokenStore');
|
|
2885
|
+
if (!isRegularTcTokenUser(jid)) return null;
|
|
2886
|
+
|
|
2887
|
+
const lidJid = this._bareLidFor(jid);
|
|
2888
|
+
if (!lidJid) return null;
|
|
2889
|
+
|
|
2890
|
+
return crypto.createHmac('sha256', salt).update(lidJid, 'utf8').digest();
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2710
2893
|
// Every address this message's session might be filed under, best first.
|
|
2711
2894
|
_signalJidCandidates(from, participant, senderPn) {
|
|
2712
2895
|
const out = [];
|
|
@@ -5428,6 +5611,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5428
5611
|
_whaDbg('[DBG] APP_STATE_KEY_SHARE_SAVE_ERR ' + e.message);
|
|
5429
5612
|
}
|
|
5430
5613
|
|
|
5614
|
+
// A key that has arrived is no longer one we are holding a request down
|
|
5615
|
+
// for; drop the hold so that if it is ever missing again the ask goes out
|
|
5616
|
+
// at once rather than a day later.
|
|
5617
|
+
if (this._appStateKeyRequests) {
|
|
5618
|
+
for (const k of keyShare.keys) {
|
|
5619
|
+
if (!k || !k.keyId) continue;
|
|
5620
|
+
try {
|
|
5621
|
+
this._appStateKeyRequests.delete(
|
|
5622
|
+
Buffer.from(k.keyId, 'hex').toString('base64'));
|
|
5623
|
+
} catch (_) {}
|
|
5624
|
+
}
|
|
5625
|
+
}
|
|
5626
|
+
|
|
5431
5627
|
this.emit('app_state_keys', { keys: keyShare.keys });
|
|
5432
5628
|
|
|
5433
5629
|
// The key is what app state was waiting on. Anything the server has been
|
package/lib/WebStore.js
CHANGED
|
@@ -63,7 +63,9 @@ function webStoreToJson(store) {
|
|
|
63
63
|
platform: store.platform || null,
|
|
64
64
|
webVersion: store.webVersion || WEB_VERSION_FALLBACK.slice(),
|
|
65
65
|
browser: store.browser || WEB_BROWSER.slice(),
|
|
66
|
-
syncFullHistory: store.syncFullHistory !== false
|
|
66
|
+
syncFullHistory: store.syncFullHistory !== false,
|
|
67
|
+
// The NCT salt (base64) a cstoken is derived from, received over app state.
|
|
68
|
+
nctSalt: store.nctSalt || null
|
|
67
69
|
});
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -87,6 +89,7 @@ function webStoreFromJson(obj) {
|
|
|
87
89
|
store.webVersion = obj.webVersion || WEB_VERSION_FALLBACK.slice();
|
|
88
90
|
store.browser = obj.browser || WEB_BROWSER.slice();
|
|
89
91
|
store.syncFullHistory = obj.syncFullHistory !== false;
|
|
92
|
+
store.nctSalt = obj.nctSalt || null;
|
|
90
93
|
|
|
91
94
|
return store;
|
|
92
95
|
}
|
|
@@ -95,8 +95,8 @@ class MissingAppStateKeyError extends Error {
|
|
|
95
95
|
* missing key is different — nothing after it can be read either, and the fix
|
|
96
96
|
* is to wait for the key rather than to carry on — so that one is thrown.
|
|
97
97
|
*/
|
|
98
|
-
function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
|
|
99
|
-
const gen = makeGenerator(state);
|
|
98
|
+
function decodeMutations(mutations, state, getKey, onMutation, validateMacs, opts) {
|
|
99
|
+
const gen = makeGenerator(state, opts);
|
|
100
100
|
const cache = new Map();
|
|
101
101
|
let skipped = 0;
|
|
102
102
|
|
|
@@ -145,7 +145,17 @@ function decodeMutations(mutations, state, getKey, onMutation, validateMacs) {
|
|
|
145
145
|
try { index = JSON.parse(data.index.toString('utf8')); }
|
|
146
146
|
catch (_) { skipped++; continue; }
|
|
147
147
|
|
|
148
|
-
onMutation({
|
|
148
|
+
onMutation({
|
|
149
|
+
index,
|
|
150
|
+
action: data.value,
|
|
151
|
+
operation: m.operation,
|
|
152
|
+
// The api version the mutation was written under. The server sends it, so
|
|
153
|
+
// report it: a caller that has to tell an old write from a new one has
|
|
154
|
+
// nothing else to go on.
|
|
155
|
+
version: data.version,
|
|
156
|
+
indexMac: m.indexBlob,
|
|
157
|
+
valueMac: theirMac
|
|
158
|
+
});
|
|
149
159
|
gen.mix({
|
|
150
160
|
indexMac: m.indexBlob,
|
|
151
161
|
valueMac: theirMac,
|
|
@@ -168,7 +178,8 @@ function decodeSnapshot(name, snapshot, getKey, getMutation, validateMacs) {
|
|
|
168
178
|
const state = newState();
|
|
169
179
|
state.version = snapshot.version;
|
|
170
180
|
|
|
171
|
-
const res = decodeMutations(snapshot.records, state, getKey, getMutation,
|
|
181
|
+
const res = decodeMutations(snapshot.records, state, getKey, getMutation,
|
|
182
|
+
validateMacs, { snapshot: true });
|
|
172
183
|
state.hash = res.hash;
|
|
173
184
|
state.indexValueMap = res.indexValueMap;
|
|
174
185
|
|
package/lib/appstate/LTHash.js
CHANGED
|
@@ -79,7 +79,15 @@ const PATCH_INTEGRITY = new LTHash(PATCH_SALT);
|
|
|
79
79
|
// indexValueMap is index MAC (base64) → { valueMac }. It is the reason a
|
|
80
80
|
// mutation can be replaced at all: without knowing the value an index used to
|
|
81
81
|
// carry, there is nothing to subtract back out.
|
|
82
|
-
|
|
82
|
+
//
|
|
83
|
+
// `snapshot` turns the subtraction off. A snapshot is the whole collection
|
|
84
|
+
// stated from nothing, so the server computed its hash by adding every record
|
|
85
|
+
// and subtracting none — including two records that happen to share an index.
|
|
86
|
+
// Displacing the first would leave us one value short of the hash the snapshot
|
|
87
|
+
// is signed against, and the collection would be thrown away for a mismatch
|
|
88
|
+
// that was ours.
|
|
89
|
+
function makeGenerator(state, opts) {
|
|
90
|
+
const snapshot = !!(opts && opts.snapshot);
|
|
83
91
|
const indexValueMap = Object.assign({}, state.indexValueMap || {});
|
|
84
92
|
const hash = Buffer.isBuffer(state.hash) ? state.hash : Buffer.alloc(HASH_BYTES);
|
|
85
93
|
const addBufs = [];
|
|
@@ -88,7 +96,7 @@ function makeGenerator(state) {
|
|
|
88
96
|
return {
|
|
89
97
|
mix({ indexMac, valueMac, remove }) {
|
|
90
98
|
const key = Buffer.from(indexMac).toString('base64');
|
|
91
|
-
const prev = indexValueMap[key];
|
|
99
|
+
const prev = snapshot ? null : indexValueMap[key];
|
|
92
100
|
if (remove) {
|
|
93
101
|
// A remove for something we never had is the server telling us about a
|
|
94
102
|
// mutation we missed. There is nothing to subtract; the hash will not
|
|
@@ -96,6 +96,8 @@ function describeIndex(index) {
|
|
|
96
96
|
case 'setting_pushName':return { kind: 'pushName' };
|
|
97
97
|
case 'clearChat': return { kind: 'clear', jid };
|
|
98
98
|
case 'deleteChat': return { kind: 'delete', jid };
|
|
99
|
+
// Account-wide, no jid: the salt the cstoken fallback is derived from.
|
|
100
|
+
case 'nct_salt_sync': return { kind: 'nct_salt_sync' };
|
|
99
101
|
default: return { kind, jid };
|
|
100
102
|
}
|
|
101
103
|
}
|
|
@@ -125,7 +125,10 @@ const ACTION_FIELDS = {
|
|
|
125
125
|
17: 'archiveChatAction',
|
|
126
126
|
20: 'markChatAsReadAction',
|
|
127
127
|
21: 'clearChatAction',
|
|
128
|
-
22: 'deleteChatAction'
|
|
128
|
+
22: 'deleteChatAction',
|
|
129
|
+
// NctSaltSyncAction { salt = 1 }. The salt a cstoken is derived from; the
|
|
130
|
+
// server distributes it into app state and a companion reads it from here.
|
|
131
|
+
80: 'nctSaltSyncAction'
|
|
129
132
|
};
|
|
130
133
|
|
|
131
134
|
function decodeSyncActionValue(buf) {
|
|
@@ -156,6 +159,8 @@ function decodeSyncActionValue(buf) {
|
|
|
156
159
|
out.clearChatAction = {}; break;
|
|
157
160
|
case 'deleteChatAction':
|
|
158
161
|
out.deleteChatAction = {}; break;
|
|
162
|
+
case 'nctSaltSyncAction':
|
|
163
|
+
out.nctSaltSyncAction = { salt: Buffer.isBuffer(a[1]) ? a[1] : null }; break;
|
|
159
164
|
case 'pushNameSetting':
|
|
160
165
|
out.pushNameSetting = { name: _str(a[1]) }; break;
|
|
161
166
|
case 'contactAction':
|
|
@@ -1119,11 +1119,13 @@ class MessageSender {
|
|
|
1119
1119
|
await this._client.ensureTcTokenBeforeSend(tcJid, routingToJid);
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
+
let tcTokenAttached = false;
|
|
1122
1123
|
if (tcStore) {
|
|
1123
1124
|
const tcEntry = tcStore.get(tcJid);
|
|
1124
1125
|
if (tcEntry && tcEntry.token && tcEntry.token.length) {
|
|
1125
1126
|
if (!tcTokenExpired(tcEntry.timestamp)) {
|
|
1126
1127
|
msgContent.push(new BinaryNode('tctoken', {}, tcEntry.token));
|
|
1128
|
+
tcTokenAttached = true;
|
|
1127
1129
|
_whaDbg('[DBG] TCTOKEN_ATTACH jid=' + tcJid);
|
|
1128
1130
|
} else {
|
|
1129
1131
|
// Expired token — clear it, keep senderTimestamp for dedupe
|
|
@@ -1132,6 +1134,19 @@ class MessageSender {
|
|
|
1132
1134
|
}
|
|
1133
1135
|
}
|
|
1134
1136
|
}
|
|
1137
|
+
|
|
1138
|
+
// cstoken — the fallback the genuine client attaches when it has no
|
|
1139
|
+
// trusted-contact token. Locally computed HMAC over the recipient's LID; a
|
|
1140
|
+
// companion holds the salt, a primary does not, so this is a no-op on an SMS
|
|
1141
|
+
// session. Never on a protocol message, and never alongside a tctoken —
|
|
1142
|
+
// exactly the `if tctoken … else if cstoken …` the real client does.
|
|
1143
|
+
if (!tcTokenAttached && !isProtocolMsg) {
|
|
1144
|
+
const cs = this._client._csTokenFor(routingToJid);
|
|
1145
|
+
if (cs && cs.length) {
|
|
1146
|
+
msgContent.push(new BinaryNode('cstoken', {}, cs));
|
|
1147
|
+
_whaDbg('[DBG] CSTOKEN_ATTACH jid=' + tcJid);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1135
1150
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1136
1151
|
|
|
1137
1152
|
// Extra children a caller needs on the stanza — the <meta appdata> a
|
|
@@ -364,18 +364,25 @@ function encodeDeviceSentMessage(destinationJid, messageBuf, phash) {
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
// ─── ProtocolMessage (field 12 of Message) ───────────────────────────────────
|
|
367
|
-
// ProtocolMessage { key=1, type=2, ephemeralExpiration=4,
|
|
368
|
-
//
|
|
367
|
+
// ProtocolMessage { key=1, type=2, ephemeralExpiration=4,
|
|
368
|
+
// appStateSyncKeyShare=6, appStateSyncKeyRequest=8, ... }
|
|
369
|
+
// Type enum: REVOKE=0, EPHEMERAL_SETTING=3, APP_STATE_SYNC_KEY_REQUEST=7
|
|
369
370
|
const PROTOCOL_MSG_REVOKE = 0;
|
|
370
371
|
const PROTOCOL_MSG_EPHEMERAL = 3;
|
|
371
372
|
|
|
373
|
+
/** ProtocolMessage.Type.APP_STATE_SYNC_KEY_REQUEST */
|
|
374
|
+
const PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST = 7;
|
|
375
|
+
|
|
372
376
|
function encodeProtocolMessage(opts) {
|
|
373
377
|
// opts: { type, key: { remoteJid, fromMe, id }, ephemeralExpiration,
|
|
374
|
-
// peerDataOperationRequestMessage }
|
|
378
|
+
// appStateSyncKeyRequest, peerDataOperationRequestMessage }
|
|
375
379
|
const parts = [];
|
|
376
380
|
if (opts.key) parts.push(field(1, WIRE_LEN, encodeMessageKey(opts.key)));
|
|
377
381
|
if (opts.type !== undefined) parts.push(field(2, WIRE_VARINT, varint(opts.type)));
|
|
378
382
|
if (opts.ephemeralExpiration !== undefined) parts.push(field(4, WIRE_VARINT, varint(opts.ephemeralExpiration)));
|
|
383
|
+
if (opts.appStateSyncKeyRequest) {
|
|
384
|
+
parts.push(field(8, WIRE_LEN, opts.appStateSyncKeyRequest));
|
|
385
|
+
}
|
|
379
386
|
// Field 16, and the Type enum value that goes with it is also 16 — a
|
|
380
387
|
// coincidence in the schema, not a mistake here.
|
|
381
388
|
if (opts.peerDataOperationRequestMessage) {
|
|
@@ -384,6 +391,21 @@ function encodeProtocolMessage(opts) {
|
|
|
384
391
|
return Buffer.concat(parts);
|
|
385
392
|
}
|
|
386
393
|
|
|
394
|
+
// ─── AppStateSyncKeyRequest ──────────────────────────────────────────────────
|
|
395
|
+
//
|
|
396
|
+
// Asking the primary device for an app-state key it never shared. Mutations are
|
|
397
|
+
// encrypted under a key the phone hands out over an encrypted message; if that
|
|
398
|
+
// message was missed, every patch signed with that key is unreadable and stays
|
|
399
|
+
// unreadable — nothing later in the collection can be decoded either. The only
|
|
400
|
+
// way out is to ask, which is what this carries.
|
|
401
|
+
//
|
|
402
|
+
// AppStateSyncKeyRequest { keyIds = 1 (repeated) }
|
|
403
|
+
// AppStateSyncKeyId { keyId = 1 }
|
|
404
|
+
function encodeAppStateSyncKeyRequest(keyIds) {
|
|
405
|
+
return Buffer.concat((keyIds || []).map(
|
|
406
|
+
id => field(1, WIRE_LEN, field(1, WIRE_LEN, bytes(id)))));
|
|
407
|
+
}
|
|
408
|
+
|
|
387
409
|
// ─── PeerDataOperationRequestMessage ─────────────────────────────────────────
|
|
388
410
|
//
|
|
389
411
|
// The channel a companion asks its own phone for something over. It travels as
|
|
@@ -980,6 +1002,8 @@ module.exports = {
|
|
|
980
1002
|
encodePeerDataOperationRequestMessage,
|
|
981
1003
|
PeerDataOperationRequestType,
|
|
982
1004
|
PROTOCOL_TYPE_PEER_DATA_OPERATION_REQUEST,
|
|
1005
|
+
PROTOCOL_TYPE_APP_STATE_SYNC_KEY_REQUEST,
|
|
1006
|
+
encodeAppStateSyncKeyRequest,
|
|
983
1007
|
encodeExtendedText,
|
|
984
1008
|
encodeContextInfo,
|
|
985
1009
|
encodeSenderKeyDistributionMessage,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.26.0",
|
|
4
4
|
"description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
|
|
5
5
|
"author": "Kunboruto20",
|
|
6
6
|
"main": "index.js",
|