whalibmob 5.24.1 → 5.25.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 +11 -3
- package/cli.js +2 -2
- package/index.d.ts +2 -0
- package/lib/BinaryNode.js +1 -1
- package/lib/Client.js +82 -0
- package/lib/SessionPaths.js +5 -5
- package/lib/WebStore.js +4 -1
- package/lib/appstate/Mutations.js +2 -0
- package/lib/appstate/SyncdProto.js +6 -1
- package/lib/messages/MessageSender.js +16 -1
- package/lib/signal/SignalStore.js +6 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2884,7 +2884,7 @@ client.on('version_update', ({ from, to, source }) => {
|
|
|
2884
2884
|
console.log('announcing', to, 'instead of', from, '—', source)
|
|
2885
2885
|
})
|
|
2886
2886
|
|
|
2887
|
-
await client.init('
|
|
2887
|
+
await client.init('919634847671')
|
|
2888
2888
|
```
|
|
2889
2889
|
|
|
2890
2890
|
**Reconnects are covered too.** The check runs from the socket-connect step
|
|
@@ -2923,8 +2923,8 @@ Set `{ refreshVersion: false }` on the client to turn it off.
|
|
|
2923
2923
|
The manual tool still works on both platforms:
|
|
2924
2924
|
|
|
2925
2925
|
```bash
|
|
2926
|
-
wa refresh-version
|
|
2927
|
-
wa refresh-version
|
|
2926
|
+
wa refresh-version 919634847671 # current build for the platform
|
|
2927
|
+
wa refresh-version 919634847671 --version 2.25.1.2 # or one you name
|
|
2928
2928
|
```
|
|
2929
2929
|
|
|
2930
2930
|
Companion sessions have never had this problem: `connectWeb()` reads the live
|
|
@@ -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/cli.js
CHANGED
|
@@ -1385,7 +1385,7 @@ async function handleLine(line) {
|
|
|
1385
1385
|
if (!ph) {
|
|
1386
1386
|
fail('usage: /pair <phone> [8-char-code]');
|
|
1387
1387
|
out(' the phone number is the account you want to link to, digits only');
|
|
1388
|
-
out(' example: /pair
|
|
1388
|
+
out(' example: /pair 919634847671');
|
|
1389
1389
|
break;
|
|
1390
1390
|
}
|
|
1391
1391
|
if (_client && _client.connected) { fail('already connected — /disconnect first'); break; }
|
|
@@ -1402,7 +1402,7 @@ async function handleLine(line) {
|
|
|
1402
1402
|
out(' links the number as a companion by QR instead of a pairing code');
|
|
1403
1403
|
out(' a QR is drawn in the terminal — scan it from the phone that owns');
|
|
1404
1404
|
out(' the number: WhatsApp → Linked Devices → Link a device');
|
|
1405
|
-
out(' example: /qrcode
|
|
1405
|
+
out(' example: /qrcode 919634847671');
|
|
1406
1406
|
break;
|
|
1407
1407
|
}
|
|
1408
1408
|
if (_client && _client.connected) { fail('already connected — /disconnect first'); break; }
|
package/index.d.ts
CHANGED
|
@@ -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
|
|
package/lib/BinaryNode.js
CHANGED
|
@@ -49,7 +49,7 @@ const _JID_SERVERS = new Set([
|
|
|
49
49
|
's.whatsapp.net', 'g.us', 'lid', 'broadcast', 'call', 'newsletter', 'c.us', 's.us'
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
|
-
// "120363427770886227@g.us" / "
|
|
52
|
+
// "120363427770886227@g.us" / "919634847671:2@s.whatsapp.net" → JID object.
|
|
53
53
|
// Returns null for anything that is not a full JID, so plain strings (and
|
|
54
54
|
// bare server tokens like "g.us", which the dictionary already encodes as a
|
|
55
55
|
// valid address) keep their existing encoding untouched.
|
package/lib/Client.js
CHANGED
|
@@ -2229,6 +2229,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2229
2229
|
case 'delete':
|
|
2230
2230
|
this.emit('chat_removed', { jid, kind: what.kind, remote: true });
|
|
2231
2231
|
break;
|
|
2232
|
+
case 'nct_salt_sync': {
|
|
2233
|
+
// The salt the cstoken fallback is derived from. The server
|
|
2234
|
+
// distributes it into app state; a SET carries the bytes, a REMOVE
|
|
2235
|
+
// withdraws it. Kept on the store so it survives a reconnect. Only a
|
|
2236
|
+
// companion ever gets one, which is why cstoken is a companion-only
|
|
2237
|
+
// thing — a primary has no salt on file and sends none, exactly as
|
|
2238
|
+
// the real primary does until it has minted its own.
|
|
2239
|
+
if (removed) {
|
|
2240
|
+
if (this._store) { this._store.nctSalt = null; this._persistStore(); }
|
|
2241
|
+
_whaDbg('[DBG] NCT_SALT cleared');
|
|
2242
|
+
} else {
|
|
2243
|
+
const salt = a.nctSaltSyncAction && a.nctSaltSyncAction.salt;
|
|
2244
|
+
if (salt && salt.length && this._store) {
|
|
2245
|
+
this._store.nctSalt = Buffer.from(salt).toString('base64');
|
|
2246
|
+
this._persistStore();
|
|
2247
|
+
_whaDbg('[DBG] NCT_SALT stored (' + salt.length + ' bytes)');
|
|
2248
|
+
this.emit('nct_salt', { bytes: salt.length });
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
break;
|
|
2252
|
+
}
|
|
2232
2253
|
default:
|
|
2233
2254
|
// Something the server tracks that this library does not model yet.
|
|
2234
2255
|
// Reported rather than dropped, so it is visible that it happened.
|
|
@@ -2707,6 +2728,67 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2707
2728
|
return lid ? lid + device + '@lid' : null;
|
|
2708
2729
|
}
|
|
2709
2730
|
|
|
2731
|
+
// ─── cstoken ────────────────────────────────────────────────────────────────
|
|
2732
|
+
//
|
|
2733
|
+
// The anti-abuse token the genuine client attaches to a message when it has no
|
|
2734
|
+
// trusted-contact token for the recipient. Unlike a tctoken it is not granted
|
|
2735
|
+
// by the server per contact — it is computed locally:
|
|
2736
|
+
//
|
|
2737
|
+
// cstoken = HMAC-SHA256(nctSalt, recipient LID)
|
|
2738
|
+
//
|
|
2739
|
+
// The salt is account-wide and arrives over app state, so only a companion
|
|
2740
|
+
// ever holds one. A primary (an SMS session) has no salt on file and sends no
|
|
2741
|
+
// cstoken, which is what the real primary does until it has minted its own.
|
|
2742
|
+
//
|
|
2743
|
+
// This mirrors whatsmeow's generateCsToken line for line: a regular user, not
|
|
2744
|
+
// the PSA account and not a bot; the recipient resolved to a LID; the HMAC
|
|
2745
|
+
// taken over that LID's bare `user@lid` string.
|
|
2746
|
+
|
|
2747
|
+
/** The stored NCT salt as bytes, or null when this session has none. */
|
|
2748
|
+
_nctSalt() {
|
|
2749
|
+
const b64 = this._store && this._store.nctSalt;
|
|
2750
|
+
if (!b64) return null;
|
|
2751
|
+
try { const b = Buffer.from(b64, 'base64'); return b.length ? b : null; }
|
|
2752
|
+
catch (_) { return null; }
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* The recipient's bare LID (`user@lid`), or null when there is no mapping.
|
|
2757
|
+
*
|
|
2758
|
+
* A JID already on the LID server is used as-is; a phone JID is resolved
|
|
2759
|
+
* through the same PN→LID map every send uses. Device and agent parts are
|
|
2760
|
+
* dropped — the token is taken over the non-AD address, as whatsmeow does.
|
|
2761
|
+
*/
|
|
2762
|
+
_bareLidFor(jid) {
|
|
2763
|
+
if (!jid) return null;
|
|
2764
|
+
const str = String(jid);
|
|
2765
|
+
const user = str.split('@')[0].split(':')[0];
|
|
2766
|
+
if (!user) return null;
|
|
2767
|
+
if (str.endsWith('@lid')) return user + '@lid';
|
|
2768
|
+
const lid = this._pnToLid && this._pnToLid.get(user);
|
|
2769
|
+
return lid ? lid + '@lid' : null;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
/**
|
|
2773
|
+
* The cstoken bytes for a recipient, or null when none should be sent.
|
|
2774
|
+
*
|
|
2775
|
+
* Returns null — so no `<cstoken>` is attached — unless a salt is on file,
|
|
2776
|
+
* the recipient is a regular user, and a LID resolves for them. Every one of
|
|
2777
|
+
* those is a reason the genuine client also sends nothing.
|
|
2778
|
+
*/
|
|
2779
|
+
_csTokenFor(jid) {
|
|
2780
|
+
const salt = this._nctSalt();
|
|
2781
|
+
if (!salt) return null;
|
|
2782
|
+
|
|
2783
|
+
const { isRegularTcTokenUser } = require('./messages/TcTokenStore');
|
|
2784
|
+
if (!isRegularTcTokenUser(jid)) return null;
|
|
2785
|
+
|
|
2786
|
+
const lidJid = this._bareLidFor(jid);
|
|
2787
|
+
if (!lidJid) return null;
|
|
2788
|
+
|
|
2789
|
+
return crypto.createHmac('sha256', salt).update(lidJid, 'utf8').digest();
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2710
2792
|
// Every address this message's session might be filed under, best first.
|
|
2711
2793
|
_signalJidCandidates(from, participant, senderPn) {
|
|
2712
2794
|
const out = [];
|
package/lib/SessionPaths.js
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
// the app state, the device cache, the LID maps, the history — used to sit
|
|
7
7
|
// side by side in one directory, named after the number:
|
|
8
8
|
//
|
|
9
|
-
// ~/.waSession/
|
|
10
|
-
// ~/.waSession/
|
|
11
|
-
// ~/.waSession/
|
|
9
|
+
// ~/.waSession/919634847671.json
|
|
10
|
+
// ~/.waSession/919634847671.signal.json
|
|
11
|
+
// ~/.waSession/919634847671.sk.json
|
|
12
12
|
// ... × 11 files × every number
|
|
13
13
|
//
|
|
14
14
|
// Fifty numbers made that five hundred files in one place, with no way to move,
|
|
15
15
|
// back up or delete one account without picking its files out of the pile by
|
|
16
16
|
// prefix. A number now gets a directory of its own:
|
|
17
17
|
//
|
|
18
|
-
// <base>/
|
|
19
|
-
// <base>/
|
|
18
|
+
// <base>/919634847671/919634847671.json
|
|
19
|
+
// <base>/919634847671/919634847671.signal.json
|
|
20
20
|
// ...
|
|
21
21
|
//
|
|
22
22
|
// The file names inside are unchanged, so every path built as
|
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
|
}
|
|
@@ -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':
|
|
@@ -853,7 +853,7 @@ class MessageSender {
|
|
|
853
853
|
// ─── Clear all Signal sessions for a phone-number recipient ─────────────────
|
|
854
854
|
//
|
|
855
855
|
// Called on error 479 (stale session). Deletes:
|
|
856
|
-
// • All Signal sessions keyed by phone-number device JIDs (e.g.
|
|
856
|
+
// • All Signal sessions keyed by phone-number device JIDs (e.g. 919634847671:0)
|
|
857
857
|
// • The corresponding LID device session if a LID mapping is known
|
|
858
858
|
//
|
|
859
859
|
// Then flushes the device-ID cache for that phone so the next send re-runs
|
|
@@ -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
|
|
@@ -43,9 +43,9 @@ const DEBOUNCE_MS = 300;
|
|
|
43
43
|
// One-time pre-keys do not live in the snapshot above. They get a file each,
|
|
44
44
|
// named one per id:
|
|
45
45
|
//
|
|
46
|
-
// <base>/
|
|
46
|
+
// <base>/919634847671/919634847671.pre-key-1.json
|
|
47
47
|
// ...
|
|
48
|
-
// <base>/
|
|
48
|
+
// <base>/919634847671/919634847671.pre-key-812.json
|
|
49
49
|
//
|
|
50
50
|
// Everything else about a pre-key is small, but there are 812 of them
|
|
51
51
|
// and every one that is consumed rewrites the whole store — so keeping them in
|
|
@@ -55,8 +55,8 @@ const DEBOUNCE_MS = 300;
|
|
|
55
55
|
// authentication folders expects to see, and a write now touches one key.
|
|
56
56
|
//
|
|
57
57
|
// The stem in front of `.pre-key-` is the one the .signal.json file already
|
|
58
|
-
// carries, so a number's mobile half (
|
|
59
|
-
// companion half (
|
|
58
|
+
// carries, so a number's mobile half (919634847671.pre-key-N.json) and its
|
|
59
|
+
// companion half (919634847671.web.pre-key-N.json) keep separate pools, as they
|
|
60
60
|
// must — and a session still sitting in the old flat layout, where every
|
|
61
61
|
// number shares one directory, does not collide with its neighbours either.
|
|
62
62
|
//
|
|
@@ -224,8 +224,8 @@ class SignalStore {
|
|
|
224
224
|
attachFile(filePath) {
|
|
225
225
|
this._filePath = filePath;
|
|
226
226
|
// The pre-key files sit beside the snapshot and share its stem, so
|
|
227
|
-
//
|
|
228
|
-
//
|
|
227
|
+
// 919634847671.signal.json gives 919634847671.pre-key-<id>.json and
|
|
228
|
+
// 919634847671.web.signal.json gives 919634847671.web.pre-key-<id>.json.
|
|
229
229
|
const base = path.basename(filePath);
|
|
230
230
|
this._pkDir = path.dirname(filePath);
|
|
231
231
|
this._pkPrefix = base.endsWith('.signal.json')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.25.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",
|