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 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
- return new BinaryNode('collection', {
2003
- name,
2004
- version: String(wantSnapshot ? 0 : st.version),
2005
- return_snapshot: wantSnapshot ? 'true' : 'false'
2006
- }, null);
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 that key. Leave the
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
- if (more.length && !opts._retried) {
2127
- const again = await this.syncAppState(more, Object.assign({}, opts, { _retried: true }));
2128
- applied += again.applied;
2129
- Object.assign(summary, again.collections);
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({ index, action: data.value, operation: m.operation });
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, validateMacs);
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
 
@@ -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
- function makeGenerator(state) {
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
- // Type enum: REVOKE=0, EPHEMERAL_SETTING=3
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.24.2",
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",