whalibmob 2.1.0 → 3.0.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 +170 -251
- package/lib/noise.js +57 -1
- package/lib/proto/MessageProto.js +11 -7
- package/lib/signal/SignalProtocol.js +45 -18
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -1,354 +1,263 @@
|
|
|
1
1
|
# whalibmob
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
mobile iOS client with full Signal Protocol end-to-end encryption, SenderKey
|
|
5
|
-
group messaging, multi-device fanout, persistent sessions, and automatic
|
|
6
|
-
reconnection.
|
|
3
|
+
> Pure JavaScript Node.js WhatsApp client library and CLI. Operates as a real mobile iOS device with full **Signal Protocol** end-to-end encryption, SenderKey group messaging, multi-device fanout, persistent sessions, and automatic reconnection. No native binaries — runs anywhere Node.js runs.
|
|
7
4
|
|
|
8
5
|
---
|
|
9
6
|
|
|
10
|
-
> **⚠️ IMPORTANT
|
|
11
|
-
> dedicated to WhatsApp for this library to work for you.** Using an existing
|
|
12
|
-
> WhatsApp account that is already active on a real phone will cause
|
|
13
|
-
> WhatsApp to ban or revoke the session. Register a fresh number that has
|
|
14
|
-
> never been used with WhatsApp before.
|
|
7
|
+
> **⚠️ IMPORTANT:** Use a **brand-new phone number** dedicated exclusively to this library. Using an existing WhatsApp account already active on a real phone will cause WhatsApp to revoke the session. Register a fresh number that has never been used with WhatsApp before.
|
|
15
8
|
|
|
16
9
|
---
|
|
17
10
|
|
|
18
|
-
##
|
|
11
|
+
## Requirements
|
|
19
12
|
|
|
20
|
-
- **
|
|
21
|
-
|
|
22
|
-
- **Signal Protocol E2E encryption** — X3DH session setup, Double Ratchet
|
|
23
|
-
for 1-to-1 messages
|
|
24
|
-
- **SenderKey group encryption** — efficient group messaging with chain-key
|
|
25
|
-
advance and SKDM distribution
|
|
26
|
-
- **Multi-device fanout** — messages delivered to all linked devices of each
|
|
27
|
-
recipient via usync device query
|
|
28
|
-
- **All media types** — images, video, audio, voice notes (PTT), documents,
|
|
29
|
-
stickers; AES-256-CBC + HKDF-SHA256 encryption, uploaded to WhatsApp CDN
|
|
30
|
-
- **Text, reactions, extended text** — full message type coverage
|
|
31
|
-
- **Participant hash (phash)** — SHA-256 participant-list validation sent
|
|
32
|
-
with every multi-device and group message
|
|
33
|
-
- **deviceSentMessage** — own linked devices (tablet, secondary phone) receive
|
|
34
|
-
a proper copy-sent wrapper so they show the message as sent
|
|
35
|
-
- **senderKeyMap** — SKDM is sent to each device only once; subsequent group
|
|
36
|
-
messages skip SKDM entirely for devices that already have the key
|
|
37
|
-
- **Automatic reconnection** — exponential backoff (1 s → 2 → 4 → 8 → 15 → 30 s)
|
|
38
|
-
- **Pre-key replenishment** — automatic upload when supply drops below 20
|
|
39
|
-
- **Persistent sessions** — JSON files on disk; no re-registration after restart
|
|
40
|
-
- **Auth confirmation** — waits for server `<success>` before declaring the
|
|
41
|
-
connection open; handles `<failure>` (session revoked / account banned)
|
|
42
|
-
- **Professional `wa` CLI** — yowsup-style interface for registration, sending,
|
|
43
|
-
listening, and interactive chat
|
|
13
|
+
- **Node.js 18** or higher
|
|
14
|
+
- A phone number that can receive SMS or voice calls
|
|
44
15
|
|
|
45
16
|
---
|
|
46
17
|
|
|
47
|
-
## Installation
|
|
18
|
+
## Global Installation
|
|
19
|
+
|
|
20
|
+
Install once, use the `wa` command from anywhere on your system:
|
|
48
21
|
|
|
49
22
|
```bash
|
|
50
|
-
|
|
51
|
-
npm install
|
|
52
|
-
npm link # makes `wa` available globally
|
|
23
|
+
npm install -g whalibmob
|
|
53
24
|
```
|
|
54
25
|
|
|
55
|
-
|
|
26
|
+
Verify it works:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
wa version
|
|
30
|
+
```
|
|
56
31
|
|
|
57
32
|
---
|
|
58
33
|
|
|
59
|
-
##
|
|
34
|
+
## Quick Start
|
|
60
35
|
|
|
61
|
-
### Check if a number
|
|
36
|
+
### 1 — Check if a number has WhatsApp
|
|
62
37
|
|
|
63
38
|
```bash
|
|
64
39
|
wa registration --check 919634847671
|
|
65
40
|
```
|
|
66
41
|
|
|
67
|
-
### Request a verification code
|
|
42
|
+
### 2 — Request a verification code
|
|
68
43
|
|
|
69
44
|
```bash
|
|
70
|
-
#
|
|
45
|
+
# Via SMS (default)
|
|
71
46
|
wa registration --request-code 919634847671
|
|
72
47
|
|
|
73
|
-
#
|
|
48
|
+
# Via voice call
|
|
74
49
|
wa registration --request-code 919634847671 --method voice
|
|
75
50
|
|
|
76
|
-
#
|
|
51
|
+
# Via an old WhatsApp account
|
|
77
52
|
wa registration --request-code 919634847671 --method wa_old
|
|
78
53
|
```
|
|
79
54
|
|
|
80
|
-
### Register with the received
|
|
55
|
+
### 3 — Register with the code you received
|
|
81
56
|
|
|
82
57
|
```bash
|
|
83
58
|
wa registration --register 919634847671 --code 123456
|
|
84
59
|
```
|
|
85
60
|
|
|
86
|
-
###
|
|
61
|
+
### 4 — Connect
|
|
87
62
|
|
|
88
63
|
```bash
|
|
89
64
|
wa connect 919634847671
|
|
90
65
|
```
|
|
91
66
|
|
|
92
|
-
|
|
67
|
+
You will enter an interactive shell:
|
|
93
68
|
|
|
94
69
|
```
|
|
95
|
-
/send
|
|
96
|
-
/presence
|
|
97
|
-
/presence away
|
|
98
|
-
/quit
|
|
70
|
+
wa> /send 911234567890@s.whatsapp.net Hello!
|
|
71
|
+
wa> /presence
|
|
72
|
+
wa> /presence away
|
|
73
|
+
wa> /quit
|
|
99
74
|
```
|
|
100
75
|
|
|
101
|
-
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## CLI Reference
|
|
79
|
+
|
|
80
|
+
### Registration
|
|
81
|
+
|
|
82
|
+
| Command | Description |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `wa registration --check <phone>` | Check if a number has WhatsApp |
|
|
85
|
+
| `wa registration --request-code <phone>` | Request SMS verification code |
|
|
86
|
+
| `wa registration --request-code <phone> --method voice` | Request code via voice call |
|
|
87
|
+
| `wa registration --request-code <phone> --method wa_old` | Request code via old WA account |
|
|
88
|
+
| `wa registration --register <phone> --code <code>` | Complete registration |
|
|
89
|
+
|
|
90
|
+
### Connection
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `wa connect <phone>` | Connect and open interactive shell |
|
|
95
|
+
| `wa listen <phone>` | Connect and print all incoming messages |
|
|
96
|
+
|
|
97
|
+
### Sending Messages
|
|
102
98
|
|
|
103
99
|
```bash
|
|
104
|
-
# Text
|
|
105
|
-
wa send
|
|
100
|
+
# Text message
|
|
101
|
+
wa send <phone> --to <jid> --text "Hello!"
|
|
106
102
|
|
|
107
|
-
# Image with caption
|
|
108
|
-
wa send
|
|
103
|
+
# Image (with optional caption)
|
|
104
|
+
wa send <phone> --to <jid> --image ./photo.jpg --caption "Check this out"
|
|
109
105
|
|
|
110
|
-
# Video
|
|
111
|
-
wa send
|
|
106
|
+
# Video (with optional caption)
|
|
107
|
+
wa send <phone> --to <jid> --video ./clip.mp4 --caption "Watch this"
|
|
112
108
|
|
|
113
|
-
# Voice note (
|
|
114
|
-
wa send
|
|
109
|
+
# Voice note (push-to-talk)
|
|
110
|
+
wa send <phone> --to <jid> --ptt ./voice.ogg
|
|
115
111
|
|
|
116
112
|
# Audio file
|
|
117
|
-
wa send
|
|
113
|
+
wa send <phone> --to <jid> --audio ./song.mp3
|
|
118
114
|
|
|
119
115
|
# Document
|
|
120
|
-
wa send
|
|
116
|
+
wa send <phone> --to <jid> --document ./report.pdf
|
|
121
117
|
|
|
122
118
|
# Sticker
|
|
123
|
-
wa send
|
|
119
|
+
wa send <phone> --to <jid> --sticker ./sticker.webp
|
|
124
120
|
|
|
125
121
|
# Emoji reaction
|
|
126
|
-
wa send
|
|
122
|
+
wa send <phone> --to <jid> --reaction 👍 --msg-id 3EB0ABCDEF123456
|
|
127
123
|
|
|
128
124
|
# Group message
|
|
129
|
-
wa send
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### Listen for incoming messages
|
|
133
|
-
|
|
134
|
-
```bash
|
|
135
|
-
wa listen 919634847671
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
### Print version
|
|
139
|
-
|
|
140
|
-
```bash
|
|
141
|
-
wa version
|
|
125
|
+
wa send <phone> --to 120363000000000000@g.us --text "Hi everyone!"
|
|
142
126
|
```
|
|
143
127
|
|
|
144
|
-
###
|
|
128
|
+
### JID Format
|
|
145
129
|
|
|
146
|
-
|
|
|
130
|
+
| Target | JID |
|
|
147
131
|
|---|---|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
| `--code <code>` | Verification code |
|
|
151
|
-
| `--to <jid>` | Recipient JID (`phone@s.whatsapp.net` or `groupid@g.us`) |
|
|
152
|
-
| `--text <msg>` | Text message body |
|
|
153
|
-
| `--caption <text>` | Caption for media messages |
|
|
154
|
-
| `--reaction <emoji>` | Emoji to send as a reaction |
|
|
155
|
-
| `--msg-id <id>` | Message ID to react to (required with `--reaction`) |
|
|
132
|
+
| Individual | `919634847671@s.whatsapp.net` |
|
|
133
|
+
| Group | `120363000000000000@g.us` |
|
|
156
134
|
|
|
157
|
-
|
|
135
|
+
> Phone numbers must include the country code, without the `+` prefix.
|
|
158
136
|
|
|
159
|
-
|
|
137
|
+
### All Options
|
|
160
138
|
|
|
161
|
-
|
|
139
|
+
| Flag | Default | Description |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `--session <dir>` | `~/.waSession` | Directory where session files are stored |
|
|
142
|
+
| `--method` | `sms` | Verification method: `sms`, `voice`, or `wa_old` |
|
|
143
|
+
| `--code` | — | Verification code received via SMS/voice |
|
|
144
|
+
| `--to` | — | Recipient JID |
|
|
145
|
+
| `--text` | — | Text message body |
|
|
146
|
+
| `--caption` | — | Caption for image or video |
|
|
147
|
+
| `--reaction` | — | Emoji to react with |
|
|
148
|
+
| `--msg-id` | — | Message ID to react to (required with `--reaction`) |
|
|
162
149
|
|
|
163
|
-
|
|
164
|
-
const { WhalibmobClient } = require('whalibmob');
|
|
150
|
+
---
|
|
165
151
|
|
|
166
|
-
|
|
152
|
+
## Sessions
|
|
167
153
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
await client.sendText('919876543210@s.whatsapp.net', 'Hello!');
|
|
171
|
-
|
|
172
|
-
// Image
|
|
173
|
-
await client.sendImage('919876543210@s.whatsapp.net', '/path/to/photo.jpg', {
|
|
174
|
-
caption: 'My photo'
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// Image from Buffer
|
|
178
|
-
const buf = require('fs').readFileSync('/path/to/photo.jpg');
|
|
179
|
-
await client.sendImage('919876543210@s.whatsapp.net', buf, { caption: 'From buffer' });
|
|
180
|
-
|
|
181
|
-
// Voice note
|
|
182
|
-
await client.sendAudio('919876543210@s.whatsapp.net', '/path/to/voice.ogg', {
|
|
183
|
-
ptt: true,
|
|
184
|
-
seconds: 12
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Document
|
|
188
|
-
await client.sendDocument('919876543210@s.whatsapp.net', '/path/to/file.pdf', {
|
|
189
|
-
fileName: 'report.pdf',
|
|
190
|
-
mimetype: 'application/pdf'
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// Reaction
|
|
194
|
-
await client.sendReaction('919876543210@s.whatsapp.net', '3EB0ABC123', '👍');
|
|
195
|
-
|
|
196
|
-
// Group message
|
|
197
|
-
await client.sendText('120363000000000000@g.us', 'Hi group!');
|
|
198
|
-
});
|
|
154
|
+
Sessions are stored as JSON files in `~/.waSession/` by default.
|
|
155
|
+
Each phone number gets its own file: `~/.waSession/<phone>.json`.
|
|
199
156
|
|
|
200
|
-
|
|
201
|
-
console.log('From:', msg.from, '|', msg.text);
|
|
202
|
-
});
|
|
157
|
+
You only register once. After that, just connect:
|
|
203
158
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
159
|
+
```bash
|
|
160
|
+
wa connect 919634847671
|
|
161
|
+
```
|
|
208
162
|
|
|
209
|
-
|
|
210
|
-
client.on('error', (err) => console.error(err));
|
|
163
|
+
To use a custom session directory:
|
|
211
164
|
|
|
212
|
-
|
|
165
|
+
```bash
|
|
166
|
+
wa connect 919634847671 --session /data/my-sessions
|
|
167
|
+
wa send 919634847671 --session /data/my-sessions --to 911234567890@s.whatsapp.net --text "Hi"
|
|
213
168
|
```
|
|
214
169
|
|
|
215
|
-
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Library API
|
|
216
173
|
|
|
217
|
-
```
|
|
174
|
+
```js
|
|
218
175
|
const {
|
|
219
|
-
|
|
220
|
-
saveStore,
|
|
176
|
+
WhalibmobClient,
|
|
221
177
|
loadStore,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
178
|
+
checkNumberStatus,
|
|
179
|
+
makeJid,
|
|
180
|
+
generateMessageId
|
|
225
181
|
} = require('whalibmob');
|
|
226
|
-
|
|
227
|
-
const storePath = '/path/to/sessions/919634847671.json';
|
|
228
|
-
|
|
229
|
-
// Create or load a session store
|
|
230
|
-
let store = loadStore(storePath) || createNewStore('919634847671');
|
|
231
|
-
|
|
232
|
-
// Check if already registered
|
|
233
|
-
const status = await checkIfRegistered(store);
|
|
234
|
-
console.log(status.status); // "ok" if registered
|
|
235
|
-
|
|
236
|
-
// Request verification code — methods: "sms", "voice", "wa_old"
|
|
237
|
-
const res = await requestSmsCode(store, 'sms');
|
|
238
|
-
console.log(res.status); // "sent" on success
|
|
239
|
-
|
|
240
|
-
// Verify the received code and save session
|
|
241
|
-
const reg = await verifyCode(store, '123456');
|
|
242
|
-
if (reg.status === 'ok') {
|
|
243
|
-
saveStore(store, storePath);
|
|
244
|
-
console.log('Registered!');
|
|
245
|
-
}
|
|
246
182
|
```
|
|
247
183
|
|
|
248
|
-
|
|
184
|
+
### Connect and send a message
|
|
249
185
|
|
|
250
|
-
|
|
186
|
+
```js
|
|
187
|
+
const { WhalibmobClient, loadStore } = require('whalibmob');
|
|
188
|
+
const path = require('path');
|
|
251
189
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
| `connected` | — | Server confirmed `<success>`, session is live |
|
|
255
|
-
| `close` | — | Connection closed |
|
|
256
|
-
| `disconnected` | — | TCP connection dropped (reconnect scheduled) |
|
|
257
|
-
| `reconnecting` | `{ delay, attempt }` | About to reconnect after backoff delay |
|
|
258
|
-
| `reconnected` | — | Reconnection succeeded |
|
|
259
|
-
| `auth_failure` | `{ reason, node }` | Server sent `<failure>` — session revoked or account banned |
|
|
260
|
-
| `error` | `Error` | Non-fatal error occurred |
|
|
261
|
-
| `message` | `{ id, from, participant, ts, text?, plaintext?, isGroup? }` | Incoming message (plaintext is a Buffer) |
|
|
262
|
-
| `receipt` | `{ id, from, type }` | Delivery or read receipt |
|
|
263
|
-
| `presence` | `{ from, available }` | Contact came online / went offline |
|
|
264
|
-
| `call` | `{ from, node }` | Incoming call (auto-rejected by the library) |
|
|
265
|
-
| `notification` | `{ type, attrs, node }` | Raw server notification |
|
|
266
|
-
| `decrypt_error` | `{ id, from, err }` | Decryption failed; retry request sent automatically |
|
|
267
|
-
| `media_conn` | `{ hosts, auth }` | Media upload server connection ready |
|
|
268
|
-
| `session_refresh` | `{ node }` | Server sent a late `<success>` (re-auth flow) |
|
|
269
|
-
| `stream_error` | `{ reason }` | Server-side stream error |
|
|
270
|
-
|
|
271
|
-
---
|
|
190
|
+
const sessionDir = path.join(process.env.HOME, '.waSession');
|
|
191
|
+
const store = loadStore('919634847671', sessionDir);
|
|
272
192
|
|
|
273
|
-
|
|
193
|
+
const client = new WhalibmobClient(store, { sessionDir });
|
|
274
194
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
~/.waSession/919634847671.json # WhatsApp credentials and keys
|
|
279
|
-
~/.waSession/919634847671.signal.json # Signal Protocol session state
|
|
280
|
-
~/.waSession/919634847671.sk.json # SenderKey group state + SKDM map
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Once registered, sessions persist across restarts. Re-registration is only
|
|
284
|
-
required if WhatsApp revokes the session (you will receive an `auth_failure`
|
|
285
|
-
event with reason `401` or `not_authorized`).
|
|
286
|
-
|
|
287
|
-
---
|
|
288
|
-
|
|
289
|
-
## Signal Protocol
|
|
195
|
+
client.on('connected', async () => {
|
|
196
|
+
await client.sendText('911234567890@s.whatsapp.net', 'Hello from whalibmob!');
|
|
197
|
+
});
|
|
290
198
|
|
|
291
|
-
|
|
199
|
+
client.on('message', msg => {
|
|
200
|
+
console.log('Received:', msg);
|
|
201
|
+
});
|
|
292
202
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
3. All subsequent messages use the Double Ratchet algorithm.
|
|
297
|
-
4. Pre-keys are automatically replenished when supply drops below 20.
|
|
298
|
-
5. Session state is persisted to disk between restarts.
|
|
203
|
+
client.on('disconnected', ({ reason }) => {
|
|
204
|
+
console.log('Disconnected:', reason);
|
|
205
|
+
});
|
|
299
206
|
|
|
300
|
-
|
|
207
|
+
await client.connect();
|
|
208
|
+
```
|
|
301
209
|
|
|
302
|
-
|
|
210
|
+
### Send media
|
|
303
211
|
|
|
304
|
-
|
|
212
|
+
```js
|
|
213
|
+
await client.sendImage ('911234567890@s.whatsapp.net', './photo.jpg', 'Caption');
|
|
214
|
+
await client.sendVideo ('911234567890@s.whatsapp.net', './clip.mp4', 'Caption');
|
|
215
|
+
await client.sendPtt ('911234567890@s.whatsapp.net', './voice.ogg');
|
|
216
|
+
await client.sendAudio ('911234567890@s.whatsapp.net', './song.mp3');
|
|
217
|
+
await client.sendDocument('911234567890@s.whatsapp.net', './report.pdf');
|
|
218
|
+
await client.sendSticker ('911234567890@s.whatsapp.net', './sticker.webp');
|
|
219
|
+
```
|
|
305
220
|
|
|
306
|
-
|
|
307
|
-
2. On first group message, a SenderKey Distribution Message (SKDM) is
|
|
308
|
-
encrypted individually for every device of every group member and sent.
|
|
309
|
-
3. Subsequent messages are encrypted once with the SenderKey and broadcast
|
|
310
|
-
— no per-member re-encryption needed.
|
|
311
|
-
4. The library tracks which devices have already received the SKDM (persisted
|
|
312
|
-
in `.sk.json`) and skips them on subsequent messages.
|
|
313
|
-
5. The chain key advances with each message (HMAC-SHA256).
|
|
314
|
-
6. Out-of-order message decryption is supported via cached message keys.
|
|
221
|
+
### Check a number
|
|
315
222
|
|
|
316
|
-
|
|
223
|
+
```js
|
|
224
|
+
const { checkNumberStatus } = require('whalibmob');
|
|
317
225
|
|
|
318
|
-
|
|
226
|
+
const result = await checkNumberStatus('919634847671');
|
|
227
|
+
// result.status → 'registered' | 'registered_blocked' | 'not_registered' | 'cooldown' | 'unknown'
|
|
228
|
+
console.log(result.status);
|
|
229
|
+
```
|
|
319
230
|
|
|
320
|
-
|
|
231
|
+
### Events
|
|
321
232
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
- SKDM distribution in groups covers all member devices.
|
|
233
|
+
| Event | Payload | Description |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| `connected` | — | Session authenticated and ready |
|
|
236
|
+
| `disconnected` | `{ reason }` | Connection closed |
|
|
237
|
+
| `auth_failure` | `{ reason }` | Auth failed (banned or session revoked) |
|
|
238
|
+
| `message` | message object | Incoming message received |
|
|
239
|
+
| `message.sent` | message object | Outgoing message confirmed by server |
|
|
240
|
+
| `error` | Error | Unhandled transport error |
|
|
331
241
|
|
|
332
242
|
---
|
|
333
243
|
|
|
334
|
-
##
|
|
335
|
-
|
|
336
|
-
The transport uses **Noise_XX_25519_AESGCM_SHA256**:
|
|
244
|
+
## Features
|
|
337
245
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
246
|
+
- **Mobile iOS fingerprint** — authentic iOS headers; WhatsApp servers treat it as an iPhone
|
|
247
|
+
- **Signal Protocol E2E encryption** — X3DH key agreement, Double Ratchet for 1-to-1 messages, fully inlined (no native binaries, no GitHub dependencies)
|
|
248
|
+
- **SenderKey group encryption** — efficient group messaging with chain-key advance and SKDM distribution
|
|
249
|
+
- **Multi-device fanout** — messages delivered to every linked device of each recipient
|
|
250
|
+
- **All media types** — images, video, audio, voice notes, documents, stickers
|
|
251
|
+
- **Media encryption** — AES-256-CBC + HKDF-SHA256; files uploaded to WhatsApp CDN
|
|
252
|
+
- **Emoji reactions** — react to any message by its ID
|
|
253
|
+
- **Automatic reconnection** — exponential backoff (1 s → 2 → 4 → 8 → 15 → 30 s cap)
|
|
254
|
+
- **Pre-key replenishment** — automatic upload when supply drops below threshold
|
|
255
|
+
- **Persistent sessions** — JSON files on disk; no re-registration after restart
|
|
256
|
+
- **Zero native dependencies** — pure JavaScript; works on Linux, macOS, Windows, Termux (Android)
|
|
346
257
|
|
|
347
258
|
---
|
|
348
259
|
|
|
349
|
-
##
|
|
350
|
-
|
|
351
|
-
The client reconnects on disconnect with exponential backoff:
|
|
260
|
+
## Reconnection Backoff
|
|
352
261
|
|
|
353
262
|
| Attempt | Delay |
|
|
354
263
|
|---|---|
|
|
@@ -361,16 +270,26 @@ The client reconnects on disconnect with exponential backoff:
|
|
|
361
270
|
|
|
362
271
|
---
|
|
363
272
|
|
|
364
|
-
## Media Encryption
|
|
365
|
-
|
|
366
|
-
Media files use WhatsApp's standard encryption scheme:
|
|
273
|
+
## Media Encryption Scheme
|
|
367
274
|
|
|
368
275
|
1. A random 32-byte media key is generated.
|
|
369
|
-
2. HKDF-SHA256 expands it
|
|
276
|
+
2. HKDF-SHA256 expands it into an IV, cipher key, and MAC key.
|
|
370
277
|
3. The file is encrypted with AES-256-CBC.
|
|
371
278
|
4. A 10-byte HMAC-SHA256 MAC is appended.
|
|
372
|
-
5. The
|
|
373
|
-
6. The media key and URL are
|
|
279
|
+
5. The ciphertext is uploaded to WhatsApp CDN.
|
|
280
|
+
6. The media key and CDN URL are embedded in the message envelope.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Transport
|
|
285
|
+
|
|
286
|
+
Uses **Noise_XX_25519_AESGCM_SHA256** over TCP to `g.whatsapp.net:443`:
|
|
287
|
+
|
|
288
|
+
1. Client sends `ClientHello` with an ephemeral X25519 public key.
|
|
289
|
+
2. Server replies `ServerHello` (ephemeral + encrypted static + payload).
|
|
290
|
+
3. Three DH steps (EE, SE, SS) derive the final session keys.
|
|
291
|
+
4. Client sends `ClientFinish` (encrypted static + encrypted payload).
|
|
292
|
+
5. Library waits for server `<success>` before emitting `connected`, or `<failure>` for `auth_failure`.
|
|
374
293
|
|
|
375
294
|
---
|
|
376
295
|
|
package/lib/noise.js
CHANGED
|
@@ -10,6 +10,61 @@ const { MOBILE_PROLOGUE, WHATSAPP_HOST, WHATSAPP_PORT } = require('./constants')
|
|
|
10
10
|
const { encodeHandshakeClientHello, encodeHandshakeClientFinish,
|
|
11
11
|
decodeServerHello, encodeClientPayload } = require('./proto');
|
|
12
12
|
|
|
13
|
+
// ─── CertChain validator (matches Baileys WA_CERT_DETAILS.SERIAL = 0) ────────
|
|
14
|
+
//
|
|
15
|
+
// Server payload inside ServerHello is a CertChain protobuf:
|
|
16
|
+
// CertChain.field2 (intermediate) → NoiseCertificate
|
|
17
|
+
// NoiseCertificate.field1 (details) → NoiseCertificate.Details
|
|
18
|
+
// Details.field2 (issuerSerial) must === 0
|
|
19
|
+
//
|
|
20
|
+
// All decoded manually to avoid a protobufjs dependency in noise.js.
|
|
21
|
+
|
|
22
|
+
function _pbReadVarint(buf, off) {
|
|
23
|
+
let v = 0, shift = 0;
|
|
24
|
+
while (off.pos < buf.length) {
|
|
25
|
+
const b = buf[off.pos++];
|
|
26
|
+
v |= (b & 0x7f) << shift;
|
|
27
|
+
if (!(b & 0x80)) break;
|
|
28
|
+
shift += 7;
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _pbReadFields(buf) {
|
|
34
|
+
const fields = {};
|
|
35
|
+
const off = { pos: 0 };
|
|
36
|
+
while (off.pos < buf.length) {
|
|
37
|
+
const tag = _pbReadVarint(buf, off);
|
|
38
|
+
const fn = tag >>> 3;
|
|
39
|
+
const wt = tag & 7;
|
|
40
|
+
if (wt === 0) {
|
|
41
|
+
fields[fn] = _pbReadVarint(buf, off);
|
|
42
|
+
} else if (wt === 2) {
|
|
43
|
+
const len = _pbReadVarint(buf, off);
|
|
44
|
+
fields[fn] = buf.slice(off.pos, off.pos + len);
|
|
45
|
+
off.pos += len;
|
|
46
|
+
} else {
|
|
47
|
+
break; // unexpected wire type — stop parsing
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return fields;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateServerCert(payloadBuf) {
|
|
54
|
+
// payloadBuf is the decrypted ServerHello.payload (CertChain protobuf)
|
|
55
|
+
const chain = _pbReadFields(payloadBuf);
|
|
56
|
+
const intermediate = chain[2]; // field 2 = intermediate NoiseCertificate
|
|
57
|
+
if (!Buffer.isBuffer(intermediate)) return; // no intermediate cert — skip
|
|
58
|
+
const cert = _pbReadFields(intermediate);
|
|
59
|
+
const details = cert[1]; // field 1 = details bytes
|
|
60
|
+
if (!Buffer.isBuffer(details)) return;
|
|
61
|
+
const det = _pbReadFields(details);
|
|
62
|
+
const issuerSerial = det[2]; // field 2 = issuerSerial (uint32)
|
|
63
|
+
if (issuerSerial !== undefined && issuerSerial !== 0) {
|
|
64
|
+
throw new Error('WA server cert validation failed: issuerSerial=' + issuerSerial + ' expected 0');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
13
68
|
// ─── Curve25519 DH helpers ───────────────────────────────────────────────────
|
|
14
69
|
|
|
15
70
|
function stripKeyPrefix(pub) {
|
|
@@ -216,7 +271,8 @@ class NoiseSocket extends EventEmitter {
|
|
|
216
271
|
this.noiseState.mixKey(sharedSE);
|
|
217
272
|
|
|
218
273
|
if (serverHello.payload) {
|
|
219
|
-
this.noiseState.decryptWithAd(serverHello.payload);
|
|
274
|
+
const certPlain = this.noiseState.decryptWithAd(serverHello.payload);
|
|
275
|
+
validateServerCert(certPlain);
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
const noisePriv = this.store.noiseKeyPair.private;
|
|
@@ -8,11 +8,13 @@ function tag(fieldNumber, wireType) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
function encodeVarint(n) {
|
|
11
|
+
// Use Math.floor/% instead of bitwise ops to handle values > 2^31 safely
|
|
12
|
+
// (JavaScript bitwise ops coerce to Int32, breaking timestamps in ms, large file sizes etc.)
|
|
11
13
|
const bytes = [];
|
|
12
|
-
let val = n
|
|
14
|
+
let val = Math.floor(n);
|
|
13
15
|
while (val > 127) {
|
|
14
|
-
bytes.push((val
|
|
15
|
-
val = val
|
|
16
|
+
bytes.push((val % 128) | 0x80);
|
|
17
|
+
val = Math.floor(val / 128);
|
|
16
18
|
}
|
|
17
19
|
bytes.push(val);
|
|
18
20
|
return Buffer.from(bytes);
|
|
@@ -101,23 +103,25 @@ function encodeVideoMessage(opts) {
|
|
|
101
103
|
field(9, WIRE_VARINT, varint(opts.height || 0)),
|
|
102
104
|
field(10, WIRE_VARINT, varint(opts.width || 0)),
|
|
103
105
|
field(11, WIRE_LEN, bytes(opts.fileEncSha256)),
|
|
104
|
-
field(
|
|
105
|
-
field(
|
|
106
|
+
field(13, WIRE_LEN, str(opts.directPath)),
|
|
107
|
+
field(14, WIRE_VARINT, varintS(opts.mediaKeyTimestamp || Math.floor(Date.now() / 1000)))
|
|
106
108
|
];
|
|
107
|
-
if (opts.jpegThumbnail) parts.push(field(16, WIRE_LEN, bytes(opts.jpegThumbnail)));
|
|
108
109
|
if (opts.gifPlayback) parts.push(field(8, WIRE_VARINT, bool(true)));
|
|
110
|
+
if (opts.jpegThumbnail) parts.push(field(16, WIRE_LEN, bytes(opts.jpegThumbnail)));
|
|
109
111
|
if (opts.contextInfo) parts.push(field(17, WIRE_LEN, encodeContextInfo(opts.contextInfo)));
|
|
110
112
|
return Buffer.concat(parts);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
function encodeAudioMessage(opts) {
|
|
116
|
+
// ptt (field 6) goes between seconds (5) and mediaKey (7) — must be in field-number order
|
|
117
|
+
const pttField = opts.ptt ? field(6, WIRE_VARINT, bool(true)) : Buffer.alloc(0);
|
|
114
118
|
const parts = [
|
|
115
119
|
field(1, WIRE_LEN, str(opts.url)),
|
|
116
120
|
field(2, WIRE_LEN, str(opts.mimetype || 'audio/ogg; codecs=opus')),
|
|
117
121
|
field(3, WIRE_LEN, bytes(opts.fileSha256)),
|
|
118
122
|
field(4, WIRE_VARINT, varint(opts.fileLength)),
|
|
119
123
|
field(5, WIRE_VARINT, varint(opts.seconds || 0)),
|
|
120
|
-
|
|
124
|
+
pttField,
|
|
121
125
|
field(7, WIRE_LEN, bytes(opts.mediaKey)),
|
|
122
126
|
field(8, WIRE_LEN, bytes(opts.fileEncSha256)),
|
|
123
127
|
field(9, WIRE_LEN, str(opts.directPath)),
|
|
@@ -11,6 +11,31 @@ const PRE_KEY_COUNT = 100;
|
|
|
11
11
|
const PRE_KEY_MIN = 10;
|
|
12
12
|
const PRE_KEY_START = 1;
|
|
13
13
|
|
|
14
|
+
// ─── Per-JID async mutex (matches Baileys encryptionMutex pattern) ────────────
|
|
15
|
+
// Prevents race conditions when two messages are encrypted simultaneously
|
|
16
|
+
// for the same Signal session (e.g. rapid sends or parallel fanout calls).
|
|
17
|
+
|
|
18
|
+
const _mutexQueues = new Map();
|
|
19
|
+
|
|
20
|
+
async function _withMutex(key, fn) {
|
|
21
|
+
// If no queue exists for this key, run immediately
|
|
22
|
+
if (!_mutexQueues.has(key)) {
|
|
23
|
+
_mutexQueues.set(key, Promise.resolve());
|
|
24
|
+
}
|
|
25
|
+
const prev = _mutexQueues.get(key);
|
|
26
|
+
let releasePrev;
|
|
27
|
+
const next = new Promise(r => { releasePrev = r; });
|
|
28
|
+
_mutexQueues.set(key, next);
|
|
29
|
+
await prev;
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
releasePrev();
|
|
34
|
+
// Cleanup: if no more waiters, remove from map
|
|
35
|
+
if (_mutexQueues.get(key) === next) _mutexQueues.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
// ─── Padding ──────────────────────────────────────────────────────────────────
|
|
15
40
|
|
|
16
41
|
function randomPadding() {
|
|
@@ -159,14 +184,17 @@ class SignalProtocol {
|
|
|
159
184
|
// ─── 1-to-1 encrypt / decrypt ─────────────────────────────────────────────
|
|
160
185
|
|
|
161
186
|
async encrypt(jid, plaintext) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
// Mutex per JID — prevents Signal session race conditions (matches Baileys encryptionMutex)
|
|
188
|
+
return _withMutex(jid, async () => {
|
|
189
|
+
const addr = jidToAddress(jid);
|
|
190
|
+
const cipher = new SessionCipher(this.store, addr);
|
|
191
|
+
const padded = pad(Buffer.from(plaintext));
|
|
192
|
+
const result = await cipher.encrypt(padded);
|
|
193
|
+
return {
|
|
194
|
+
type: result.type === 3 ? 'pkmsg' : 'msg',
|
|
195
|
+
ciphertext: Buffer.from(result.body, 'binary')
|
|
196
|
+
};
|
|
197
|
+
});
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
async decrypt(jid, type, ciphertext) {
|
|
@@ -182,18 +210,17 @@ class SignalProtocol {
|
|
|
182
210
|
}
|
|
183
211
|
|
|
184
212
|
// ─── Bulk encrypt for multiple devices (MD fanout) ─────────────────────────
|
|
213
|
+
// All devices encrypted in parallel (like Baileys Promise.all).
|
|
214
|
+
// Each JID is protected by its own mutex so parallel calls on the
|
|
215
|
+
// same JID are still serialized — no Signal session corruption possible.
|
|
185
216
|
// Returns [{jid, type, ciphertext}]
|
|
186
217
|
async bulkEncryptForDevices(jids, plaintext) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// Skip devices with no session or errors
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return results;
|
|
218
|
+
const settled = await Promise.allSettled(
|
|
219
|
+
jids.map(jid => this.encrypt(jid, plaintext).then(enc => ({ jid, type: enc.type, ciphertext: enc.ciphertext })))
|
|
220
|
+
);
|
|
221
|
+
return settled
|
|
222
|
+
.filter(r => r.status === 'fulfilled')
|
|
223
|
+
.map(r => r.value);
|
|
197
224
|
}
|
|
198
225
|
|
|
199
226
|
// ─── SenderKey group encrypt / decrypt ────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whalibmob",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "WhatsApp mobile-only Node.js client library with Signal Protocol encryption",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"wa": "./cli.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"cli.js",
|
|
12
|
+
"lib/**/*.js"
|
|
13
|
+
],
|
|
9
14
|
"type": "commonjs",
|
|
10
15
|
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"whatsapp",
|
|
18
|
+
"signal-protocol",
|
|
19
|
+
"mobile",
|
|
20
|
+
"client",
|
|
21
|
+
"encryption"
|
|
22
|
+
],
|
|
11
23
|
"dependencies": {
|
|
12
24
|
"@noble/curves": "^1.8.1",
|
|
13
25
|
"@noble/hashes": "^1.7.2",
|